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)
+58 -3
View File
@@ -23,14 +23,19 @@ class NodoLocal {
final bool esDirectorio;
}
/// Minimal playable local track (Design "Phase 1 minimal shape" — no
/// artist/album/duration metadata per spec's "Not in this delta"). Pure
/// DTO — no behavior, so no unit tests are warranted for it on its own.
/// Playable local track (Phase 2 extends the Phase 1 minimal shape with
/// resolved embedded metadata — Design "Data Flow"). Pure DTO — no
/// behavior, so no unit tests are warranted for it on its own beyond
/// construction (`pista_local_test.dart`).
class PistaLocal {
const PistaLocal({
required this.documentId,
required this.titulo,
required this.contentUri,
this.artista,
this.embeddedArtUri,
this.bitrate,
this.sampleRate,
});
/// Opaque SAF document id for this track.
@@ -43,4 +48,54 @@ class PistaLocal {
/// Playable `content://` URI resolved via
/// `FuenteMusicaLocalAuto.uriContenidoDePista`.
final String contentUri;
/// Resolved embedded artist metadata, when available (Design "Interfaces
/// / Contracts" — `readAudioMetadataBatch`). `null` when unresolved or
/// unavailable.
final String? artista;
/// Resolved embedded-art `content://` URI served via the app's
/// `FileProvider` cache (Design ADR-1). `null` when there is no embedded
/// picture or it could not be resolved.
final String? embeddedArtUri;
/// Resolved bitrate in bits per second, when available.
final int? bitrate;
/// Resolved sample rate in Hz, only available on API 31+ (Design ADR-5).
/// `null` on older API levels or when unresolved.
final int? sampleRate;
}
/// Resolved embedded metadata for a single local track (Design "Interfaces
/// / Contracts"): one entry per requested `documentId`, all fields
/// individually nullable (per-field parse failures degrade gracefully
/// instead of dropping the whole entry). Pure DTO — no behavior, so no
/// unit tests are warranted for it on its own beyond construction
/// (`pista_local_test.dart`).
class MetadatosPista {
const MetadatosPista({
this.titulo,
this.artista,
this.artUri,
this.bitrate,
this.sampleRate,
});
/// Resolved embedded title, or `null` when absent/unparseable.
final String? titulo;
/// Resolved embedded artist, or `null` when absent/unparseable.
final String? artista;
/// Resolved embedded-art `content://` URI (Design ADR-1), or `null` when
/// there is no embedded picture or it could not be resolved/served.
final String? artUri;
/// Resolved bitrate in bits per second, or `null` when unknown.
final int? bitrate;
/// Resolved sample rate in Hz (API 31+ only, Design ADR-5), or `null`
/// when unknown/unsupported on this API level.
final int? sampleRate;
}
+79
View File
@@ -1,3 +1,5 @@
import 'dart:collection';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -90,6 +92,50 @@ abstract class FuenteMusicaLocalAuto {
/// `null` if it cannot be resolved (stale id, revoked permission). Never
/// throws.
Future<String?> uriContenidoDePista(String documentId);
/// Batched embedded-metadata resolution (Design "Interfaces / Contracts",
/// Phase 2) for [documentIds] — one map entry per requested id that was
/// resolvable. Never throws: an empty [documentIds], a missing root
/// folder, or any channel failure degrades to `{}`. A native row with a
/// null/missing field yields a [MetadatosPista] with that field `null`,
/// never a crash or a dropped entry.
Future<Map<String, MetadatosPista>> metadatosDe(List<String> documentIds);
}
/// In-memory, session-scoped LRU cache of resolved [MetadatosPista] (Design
/// ADR-2): a flat `LinkedHashMap`, bounded to [_capacidad] entries,
/// LRU-by-ACCESS (not just insertion) — [obtener] on a hit re-inserts the
/// entry to refresh its recency, so a hot re-visited entry survives even
/// under eviction pressure. Deliberately NOT folder-scoped: paging a large
/// folder must not evict an earlier page's cached metadata (Design ADR-2's
/// rationale — 256 ≈ 5 pages of 50). In-memory only; dies with the process,
/// so there is no persistence-staleness concern.
class CacheMetadatosSesion {
static const _capacidad = 256;
final LinkedHashMap<String, MetadatosPista> _entradas =
LinkedHashMap<String, MetadatosPista>();
/// Returns the cached [MetadatosPista] for [documentId], or `null` on a
/// miss. A hit refreshes [documentId]'s recency (moves it to the
/// most-recently-used end) so it survives longer under LRU eviction.
MetadatosPista? obtener(String documentId) {
final valor = _entradas.remove(documentId);
if (valor == null) return null;
_entradas[documentId] = valor;
return valor;
}
/// Stores [metadatos] under [documentId], refreshing its recency.
/// Evicts the least-recently-used entry (the current first key) when
/// insertion would exceed [_capacidad].
void guardar(String documentId, MetadatosPista metadatos) {
_entradas.remove(documentId);
_entradas[documentId] = metadatos;
if (_entradas.length > _capacidad) {
_entradas.remove(_entradas.keys.first);
}
}
}
/// Channel-backed [FuenteMusicaLocalAuto] implementation (Design "Hand-rolled
@@ -202,6 +248,39 @@ class FuenteMusicaLocalAutoImpl implements FuenteMusicaLocalAuto {
}
}
@override
Future<Map<String, MetadatosPista>> metadatosDe(
List<String> documentIds,
) async {
if (documentIds.isEmpty) return const {};
try {
final uri = await _uriPersistida();
if (uri == null || uri.isEmpty) return const {};
final crudos = await _canal.invokeMethod<List<Object?>>(
'readAudioMetadataBatch',
{'treeUri': uri, 'documentIds': documentIds},
);
if (crudos == null) return const {};
final resultado = <String, MetadatosPista>{};
for (final fila in crudos.whereType<Map<Object?, Object?>>()) {
final documentId = fila['documentId'] as String?;
if (documentId == null || documentId.isEmpty) continue;
resultado[documentId] = MetadatosPista(
titulo: fila['titulo'] as String?,
artista: fila['artista'] as String?,
artUri: fila['artUri'] as String?,
bitrate: (fila['bitrate'] as num?)?.toInt(),
sampleRate: (fila['sampleRate'] as num?)?.toInt(),
);
}
return resultado;
} catch (_) {
// Cold-start / revoked-permission / channel-error safety (Design
// "Interfaces / Contracts" — metadatosDe never throws).
return const {};
}
}
/// Maps a raw `listAudioChildren` row to a [NodoLocal], re-validating
/// audio files via [esArchivoAudio] (Design "Interfaces / Contracts" —
/// defense-in-depth on top of the native `audio/*` filter). Returns `null`
+449 -22
View File
@@ -120,6 +120,42 @@ String? subtituloCalidad(Emisora e) {
return null;
}
/// Formats a human-readable quality hint for a local track's
/// `displaySubtitle` (Design ADR-5 "unknown → omit" discipline, reused for
/// local tracks): `"<bitrate> kbps · <sampleRate> kHz"` when both are
/// known, just the bitrate or just the sample rate when only one is known,
/// and `null` (never `""`, never a string containing the literal `"null"`)
/// when [metadatos] is `null` or both fields are unknown. `bitrate` is
/// converted from bits/sec to kbps (rounded); `sampleRate` from Hz to kHz,
/// trimmed of a trailing `.0`.
String? subtituloCalidadLocal(MetadatosPista? metadatos) {
if (metadatos == null) return null;
final bitrate = metadatos.bitrate;
final bitrateConocido = bitrate != null && bitrate > 0;
final sampleRate = metadatos.sampleRate;
final sampleRateConocido = sampleRate != null && sampleRate > 0;
if (bitrateConocido && sampleRateConocido) {
return '${(bitrate / 1000).round()} kbps · ${_formatKhz(sampleRate)} kHz';
}
if (bitrateConocido) return '${(bitrate / 1000).round()} kbps';
if (sampleRateConocido) return '${_formatKhz(sampleRate)} kHz';
return null;
}
/// Formats [sampleRateHz] (in Hz) as a trimmed kHz string: `44100` ->
/// `'44.1'`, `48000` -> `'48'` — never a trailing `.0` or extra zeros.
String _formatKhz(int sampleRateHz) {
var texto = (sampleRateHz / 1000).toStringAsFixed(2);
while (texto.endsWith('0')) {
texto = texto.substring(0, texto.length - 1);
}
if (texto.endsWith('.')) {
texto = texto.substring(0, texto.length - 1);
}
return texto;
}
/// Browse-source abstraction for the Android Auto media tree (Design
/// "getChildren data source, cold-start safe"). Kept separate from
/// `EstadoRadio` so a headless Auto bind (`main()` runs but the widget tree
@@ -198,6 +234,20 @@ class ConstructorArbolAuto {
/// is therefore irrelevant to correctness.
static const _prefijoCarpetaLocalPaginada = 'carpeta_local_pag:';
/// Sort-mode local-music id prefix (Design ADR-4, Phase 2):
/// `carpeta_local_ord:<modo>:<pagina>:<docId>`. Collision-free against
/// every other prefix/bare id in this class — diverges from
/// [_prefijoCarpetaLocal] at index 13 (`_` vs `:`) and from
/// [_prefijoCarpetaLocalBucket]/[_prefijoCarpetaLocalPaginada] at the char
/// right after `carpeta_local_` (`o` vs `b`/`p`).
static const _prefijoCarpetaLocalOrd = 'carpeta_local_ord:';
/// Alphabetical-bucket local-music id prefix (Design ADR-4, Phase 2):
/// `carpeta_local_bucket:<idxBucket>:<pagina>:<docId>`. Collision-free
/// against every other prefix/bare id in this class (see
/// [_prefijoCarpetaLocalOrd]'s doc for the divergence proof).
static const _prefijoCarpetaLocalBucket = 'carpeta_local_bucket:';
/// Separate cap for favorite-group folders under `Favoritos` (Design
/// "group-folder ordering and cap"): a folder tap costs more driver
/// attention than a station scroll, so this is tunable independently of
@@ -210,6 +260,16 @@ class ConstructorArbolAuto {
/// for a future native page-offset parameter.
static const _maxItemsCarpetaLocal = 50;
/// Quality-sort track-count cap (Design ADR-3): above this, the
/// "Ordenar por calidad" entry is omitted instead of paying an unbounded
/// per-file `MediaMetadataRetriever` extraction cost — first-pass value,
/// not yet hardware-validated (Design "Open Questions").
static const _maxPistasParaOrdenCalidad = 150;
/// Bucket-eligibility track-count threshold (Design ADR-4): buckets add
/// no value for small folders, so they're only offered above this count.
static const _minPistasParaBuckets = 50;
/// Content-style extras (Design "content style", optional polish): list
/// (1) for the root's folders, grid (2) for playable station items.
static const _contentStyleLista = {
@@ -342,50 +402,254 @@ class ConstructorArbolAuto {
extras: _contentStyleLista,
);
/// Maps native [NodoLocal]s to browse-tree `MediaItem`s, paged (Design
/// "Lazy per-folder enumeration" + ADR-3 pagination): the full [nodos]
/// list is sorted alphabetically by [NodoLocal.nombre] — cheap, no
/// `MediaItem` built yetthen sliced to [pagina] via [paginaDe] BEFORE
/// any `MediaItem` is constructed, and only that slice (at most [tamano]
/// entries) is mapped through [construirItem] (Design "slice the cheap
/// list, then map — never map-then-slice", the memory-efficiency
/// invariant). A trailing non-playable, browsable "Más…" item is appended
/// whenever [hayPaginaSiguiente] says more items remain beyond this page;
/// selecting it feeds back into [hijosMusicaLocal] to reveal the next
/// page, so no item is ever permanently unreachable (Spec "Local Music
/// Folder Item Cap and Paging"). An empty [nodos] (or a stale [pagina]
/// beyond the folder's range) returns `[]`, never an error (Spec
/// "browsing an empty subfolder").
/// Maps native [NodoLocal]s to browse-tree `MediaItem`s, paged AND
/// metadata-backed (Design "Lazy per-folder enumeration" + ADR-3
/// pagination + Phase 2 "Data Flow"): the full [nodos] list is sorted
/// alphabetically by [NodoLocal.nombre]cheap, no `MediaItem` built yet
/// — then sliced to [pagina] via [paginaDe] BEFORE any metadata is
/// resolved or `MediaItem` is constructed (Design "slice the cheap list,
/// then map — never map-then-slice", the memory-efficiency invariant).
/// [metadatosDe] is then awaited for ONLY the sliced page's non-directory
/// `documentId`s — never the whole folder — and only THEN is the page
/// mapped through [construirItem] with the resolved metadata map. A
/// trailing non-playable, browsable "Más…" item is appended whenever
/// [hayPaginaSiguiente] says more items remain beyond this page; selecting
/// it feeds back into [hijosMusicaLocal] to reveal the next page, so no
/// item is ever permanently unreachable (Spec "Local Music Folder Item Cap
/// and Paging"). An empty [nodos] (or a stale [pagina] beyond the
/// folder's range) returns `[]`, never an error (Spec "browsing an empty
/// subfolder").
///
/// [construirItem] is `@visibleForTesting` — injectable ONLY so a test
/// spy can assert the exact `min(tamano, remaining)` call-count invariant
/// (Design ADR-3); production callers never pass it.
List<MediaItem> itemsLocales(
Future<List<MediaItem>> itemsLocales(
List<NodoLocal> nodos, {
required String documentIdPadre,
required Future<Map<String, MetadatosPista>> Function(List<String>)
metadatosDe,
int pagina = 0,
int tamano = _maxItemsCarpetaLocal,
@visibleForTesting MediaItem Function(NodoLocal)? construirItem,
}) {
@visibleForTesting
MediaItem Function(NodoLocal, Map<String, MetadatosPista>)? construirItem,
}) async {
final construir = construirItem ?? _itemLocal;
final ordenados = [...nodos]..sort((a, b) => a.nombre.compareTo(b.nombre));
final paginaActual = paginaDe(ordenados, pagina: pagina, tamano: tamano);
final items = paginaActual.map(construir).toList();
final docIds = paginaActual
.where((n) => !n.esDirectorio)
.map((n) => n.documentId)
.toList();
final metadatos = await metadatosDe(docIds);
final items = paginaActual.map((n) => construir(n, metadatos)).toList();
if (hayPaginaSiguiente(ordenados.length, pagina: pagina, tamano: tamano)) {
items.add(_itemMasLocal(documentIdPadre, pagina + 1));
}
if (pagina == 0) {
final totalPistas = nodos.where((n) => !n.esDirectorio).length;
final prepend = <MediaItem>[
if (ofreceOrdenCalidad(totalPistas))
_itemModoOrdenCalidad(documentIdPadre),
if (ofreceBuckets(totalPistas))
for (var i = 0; i < _rangosBucket.length; i++)
_itemBucket(documentIdPadre, i, _rangosBucket[i].$1),
];
return [...prepend, ...items];
}
return items;
}
MediaItem _itemLocal(NodoLocal nodo) {
/// Whether the "Ordenar por calidad" mode entry should be offered for a
/// folder with [totalPistas] audio files (Design ADR-3): present for
/// `0 < totalPistas <= 150`, omitted otherwise (empty folder or above the
/// cap) instead of paying an unbounded metadata-extraction cost.
bool ofreceOrdenCalidad(int totalPistas) =>
totalPistas > 0 && totalPistas <= _maxPistasParaOrdenCalidad;
/// Whether alphabetical bucket entries should be offered for a folder
/// with [totalPistas] audio files (Design ADR-4): buckets add no value
/// for small folders.
bool ofreceBuckets(int totalPistas) => totalPistas > _minPistasParaBuckets;
/// The "Ordenar por calidad" mode-entry `MediaItem` (Design ADR-4):
/// non-playable, id `carpeta_local_ord:calidad:0:<documentIdPadre>` —
/// always page 0 of the sorted view, round-trips via [ordenLocalDesde].
/// Hardcoded Spanish label, matching every other car-tree label in this
/// file — never routed through `AppLocalizations` (established
/// car-tree-label precedent, see [_tituloMasLocal]).
MediaItem _itemModoOrdenCalidad(String documentIdPadre) => _carpeta(
'${_prefijoCarpetaLocalOrd}calidad:0:$documentIdPadre',
'Ordenar por calidad',
);
/// A single bucket-folder `MediaItem` (Design ADR-4): non-playable, id
/// `carpeta_local_bucket:<idx>:0:<documentIdPadre>` — always page 0,
/// round-trips via [bucketLocalDesde]. [etiqueta] is the hardcoded
/// alphabetical-range label (e.g. `'A-F'`), matching every other
/// car-tree label in this file — never routed through `AppLocalizations`.
MediaItem _itemBucket(String documentIdPadre, int idx, String etiqueta) =>
_carpeta('$_prefijoCarpetaLocalBucket$idx:0:$documentIdPadre', etiqueta);
/// Whether [id] identifies a sort-mode local-music request (Design
/// ADR-4, Phase 2).
bool esCarpetaLocalOrdMediaId(String id) =>
id.startsWith(_prefijoCarpetaLocalOrd);
/// Whether [id] identifies an alphabetical-bucket local-music request
/// (Design ADR-4, Phase 2).
bool esCarpetaLocalBucketMediaId(String id) =>
id.startsWith(_prefijoCarpetaLocalBucket);
/// Parses a `carpeta_local_ord:<modo>:<pagina>:<docId>` [id] into its
/// `(modo, documentId, pagina)` triple (Design ADR-4): the prefix is
/// stripped by length, then the remainder is split on the FIRST two `:`
/// only — `modo` never contains a colon, `pagina` never contains a
/// colon, and everything after the second `:` (including further
/// colons/slashes) is the raw SAF documentId verbatim, mirroring
/// [paginaCarpetaLocalDesde]'s split-on-first-colon chain extended by one
/// field. Only meaningful when [esCarpetaLocalOrdMediaId] is `true`.
(String modo, String documentId, int pagina) ordenLocalDesde(String id) {
final resto = id.substring(_prefijoCarpetaLocalOrd.length);
final primerColon = resto.indexOf(':');
final modo = resto.substring(0, primerColon);
final resto2 = resto.substring(primerColon + 1);
final segundoColon = resto2.indexOf(':');
final pagina = int.parse(resto2.substring(0, segundoColon));
final documentId = resto2.substring(segundoColon + 1);
return (modo, documentId, pagina);
}
/// Parses a `carpeta_local_bucket:<idxBucket>:<pagina>:<docId>` [id] into
/// its `(idxBucket, documentId, pagina)` triple (Design ADR-4), mirroring
/// [ordenLocalDesde]'s split-on-first-two-colons chain. Only meaningful
/// when [esCarpetaLocalBucketMediaId] is `true`.
(int idxBucket, String documentId, int pagina) bucketLocalDesde(String id) {
final resto = id.substring(_prefijoCarpetaLocalBucket.length);
final primerColon = resto.indexOf(':');
final idxBucket = int.parse(resto.substring(0, primerColon));
final resto2 = resto.substring(primerColon + 1);
final segundoColon = resto2.indexOf(':');
final pagina = int.parse(resto2.substring(0, segundoColon));
final documentId = resto2.substring(segundoColon + 1);
return (idxBucket, documentId, pagina);
}
/// The trailing "load more" item for the quality-sort view (Design ADR-4,
/// mirrors [_itemMasLocal]): id
/// `carpeta_local_ord:<modo>:<siguientePagina>:<documentIdPadre>`.
MediaItem _itemMasLocalOrd(
String documentIdPadre,
String modo,
int siguientePagina,
) => MediaItem(
id: '$_prefijoCarpetaLocalOrd$modo:$siguientePagina:$documentIdPadre',
title: _tituloMasLocal,
playable: false,
extras: _contentStyleLista,
);
/// The trailing "load more" item for a bucket view (Design ADR-4, mirrors
/// [_itemMasLocal]): id
/// `carpeta_local_bucket:<idx>:<siguientePagina>:<documentIdPadre>`.
MediaItem _itemMasLocalBucket(
String documentIdPadre,
int idxBucket,
int siguientePagina,
) => MediaItem(
id: '$_prefijoCarpetaLocalBucket$idxBucket:$siguientePagina:$documentIdPadre',
title: _tituloMasLocal,
playable: false,
extras: _contentStyleLista,
);
/// Quality-sort view (Design ADR-3, Data Flow): resolves metadata for
/// EVERY audio file in [nodos] via ONE batched [metadatosDe] call
/// (full-folder, NOT page-scoped — the sort key requires every track's
/// bitrate up front), sorts via [ordenarPorCalidadLocal], THEN applies
/// the existing [paginaDe] slicing. Directory nodes are excluded (Design
/// "quality sort applies to tracks only").
Future<List<MediaItem>> itemsLocalesOrdenCalidad(
List<NodoLocal> nodos, {
required String documentIdPadre,
required Future<Map<String, MetadatosPista>> Function(List<String>)
metadatosDe,
int pagina = 0,
int tamano = _maxItemsCarpetaLocal,
@visibleForTesting
MediaItem Function(NodoLocal, Map<String, MetadatosPista>)? construirItem,
}) async {
final construir = construirItem ?? _itemLocal;
final pistas = nodos.where((n) => !n.esDirectorio).toList();
final docIds = pistas.map((n) => n.documentId).toList();
final metadatos = await metadatosDe(docIds);
final ordenados = ordenarPorCalidadLocal(pistas, metadatos);
final paginaActual = paginaDe(ordenados, pagina: pagina, tamano: tamano);
final items = paginaActual.map((n) => construir(n, metadatos)).toList();
if (hayPaginaSiguiente(ordenados.length, pagina: pagina, tamano: tamano)) {
items.add(_itemMasLocalOrd(documentIdPadre, 'calidad', pagina + 1));
}
return items;
}
/// Alphabetical-bucket view (Design ADR-4, Data Flow): partitions
/// [nodos] via [bucketsDe] (name-only, cheap), selects [idxBucket], sorts
/// that bucket's tracks by name, slices to [pagina], then resolves
/// metadata ONLY for the sliced page's docIds (Design "only modo=calidad
/// pays the metadata cost" — bucket browsing stays page-scoped like the
/// default name-sort view). An out-of-range [idxBucket] returns `[]`,
/// never throws.
Future<List<MediaItem>> itemsLocalesBucket(
List<NodoLocal> nodos, {
required String documentIdPadre,
required int idxBucket,
required Future<Map<String, MetadatosPista>> Function(List<String>)
metadatosDe,
int pagina = 0,
int tamano = _maxItemsCarpetaLocal,
@visibleForTesting
MediaItem Function(NodoLocal, Map<String, MetadatosPista>)? construirItem,
}) async {
final buckets = bucketsDe(nodos);
if (idxBucket < 0 || idxBucket >= buckets.length) return const [];
final construir = construirItem ?? _itemLocal;
final ordenados = [...buckets[idxBucket].nodos]
..sort((a, b) => a.nombre.compareTo(b.nombre));
final paginaActual = paginaDe(ordenados, pagina: pagina, tamano: tamano);
final docIds = paginaActual
.where((n) => !n.esDirectorio)
.map((n) => n.documentId)
.toList();
final metadatos = await metadatosDe(docIds);
final items = paginaActual.map((n) => construir(n, metadatos)).toList();
if (hayPaginaSiguiente(ordenados.length, pagina: pagina, tamano: tamano)) {
items.add(_itemMasLocalBucket(documentIdPadre, idxBucket, pagina + 1));
}
return items;
}
MediaItem _itemLocal(NodoLocal nodo, Map<String, MetadatosPista> metadatos) {
if (nodo.esDirectorio) {
return _carpeta('$_prefijoCarpetaLocal${nodo.documentId}', nodo.nombre);
}
final meta = metadatos[nodo.documentId];
final tituloMeta = meta?.titulo?.trim();
final titulo = (tituloMeta != null && tituloMeta.isNotEmpty)
? tituloMeta
: _tituloDesdeNombre(nodo.nombre);
final artUriMeta = meta?.artUri?.trim();
final artUri = (artUriMeta != null && artUriMeta.isNotEmpty)
? artUriMeta
: artUriLocal(nodo.documentId);
final artistaMeta = meta?.artista?.trim();
return MediaItem(
id: '$_prefijoPista${nodo.documentId}',
title: _tituloDesdeNombre(nodo.nombre),
title: titulo,
artist: (artistaMeta != null && artistaMeta.isNotEmpty)
? artistaMeta
: null,
playable: true,
artUri: Uri.parse(artUriLocal(nodo.documentId)),
artUri: Uri.parse(artUri),
displaySubtitle: subtituloCalidadLocal(meta),
extras: _contentStyleGrid,
);
}
@@ -515,6 +779,87 @@ String artUriLocal(String documentId) =>
'android.resource://es.freetimelab.pluriwave/drawable/'
'station_art_${_nombresArte[indiceArtePara(documentId)]}';
/// Bitrate-descending comparator for two resolved [MetadatosPista] (Design
/// ADR-3), mirroring [OrdenEmisoras.calidad]'s shape (`orden_emisoras.dart`)
/// — no code sharing forced, different types. An unknown bitrate (`null`
/// or `<= 0`) always sorts AFTER every known-bitrate entry; two unknowns
/// compare equal. Never throws.
int compararCalidadLocal(MetadatosPista? a, MetadatosPista? b) {
final bitrateA = a?.bitrate;
final bitrateB = b?.bitrate;
final conocidoA = bitrateA != null && bitrateA > 0;
final conocidoB = bitrateB != null && bitrateB > 0;
if (!conocidoA && !conocidoB) return 0;
if (!conocidoA) return 1;
if (!conocidoB) return -1;
return bitrateB.compareTo(bitrateA);
}
/// Returns a bitrate-descending sorted COPY of [nodos] (Design ADR-3),
/// resolving each node's bitrate via [metadatos] (keyed by `documentId`) —
/// a node absent from [metadatos] (or with a `null`/`<= 0` bitrate) is
/// treated as unknown and sorts last, via [compararCalidadLocal]. Never
/// throws.
List<NodoLocal> ordenarPorCalidadLocal(
List<NodoLocal> nodos,
Map<String, MetadatosPista> metadatos,
) {
final ordenados = List<NodoLocal>.from(nodos);
ordenados.sort(
(a, b) => compararCalidadLocal(
metadatos[a.documentId],
metadatos[b.documentId],
),
);
return ordenados;
}
/// Fixed alphabetical bucket ranges (Design "User browses name buckets"):
/// `(etiqueta, desde, hasta)`, each a contiguous, lowercase, single-letter
/// first-letter range. Shared by [bucketsDe] (partitioning) and
/// [ConstructorArbolAuto]'s page-0 prepend wiring (label text). A name
/// that doesn't start with an ASCII letter (blank, digit, symbol) never
/// matches any of these — the spec doesn't define a catch-all "other"
/// bucket.
const _rangosBucket = [
('A-F', 'a', 'f'),
('G-M', 'g', 'm'),
('N-S', 'n', 's'),
('T-Z', 't', 'z'),
];
/// One alphabetical name-bucket's result (Design "User browses name
/// buckets"): [etiqueta] is the fixed range label (e.g. `'A-F'`), [nodos]
/// is the (possibly empty) list of tracks whose first letter falls in that
/// range.
class BucketLocal {
const BucketLocal({required this.etiqueta, required this.nodos});
final String etiqueta;
final List<NodoLocal> nodos;
}
/// Partitions [nodos] into the 4 fixed [_rangosBucket] alphabetical ranges
/// (Design ADR-4), using ONLY [NodoLocal.nombre] — no metadata dependency,
/// so this function structurally cannot call `metadatosDe` (its signature
/// doesn't receive one). Directory nodes are excluded (buckets are a
/// track-only view). A bucket with zero matches is still returned with an
/// empty `nodos` list, never omitted or an error (Spec "Bucket with no
/// matching tracks").
List<BucketLocal> bucketsDe(List<NodoLocal> nodos) {
final pistas = nodos.where((n) => !n.esDirectorio).toList();
return _rangosBucket.map((rango) {
final (etiqueta, desde, hasta) = rango;
final coincidencias = pistas.where((n) {
final recortado = n.nombre.trim();
if (recortado.isEmpty) return false;
final letra = recortado[0].toLowerCase();
return letra.compareTo(desde) >= 0 && letra.compareTo(hasta) <= 0;
}).toList();
return BucketLocal(etiqueta: etiqueta, nodos: coincidencias);
}).toList();
}
/// Local-music `getChildren` dispatch (Design "Data Flow"): resolves
/// [parentMediaId] against the `musica_local` root (`fuente.hijos('')`) or a
/// `carpeta_local:<id>` subfolder (`fuente.hijos(id)`), mapping the result
@@ -526,11 +871,92 @@ String artUriLocal(String documentId) =>
/// "cold-start safe", mirrors `FuenteEmisorasAutoLocal`'s pattern; Spec
/// "Browse requested before app state is loaded" / "Permission revoked or
/// never granted").
/// Session-scoped metadata cache shared across every `hijosMusicaLocal`
/// call (Design ADR-2) — module-level singleton, mirroring
/// `servicio_audio.dart`'s `_fuenteMusicaLocalGlobal` pattern: paging a
/// large folder across multiple `getChildren` calls must NOT evict an
/// earlier page's cached metadata, which requires the cache to outlive any
/// single call.
final CacheMetadatosSesion _cacheMetadatosLocal = CacheMetadatosSesion();
/// Wraps [fuente]'s raw `metadatosDe` with [_cacheMetadatosLocal] (Design
/// "Data Flow" — `metadatosDe(slice.trackDocIds) — CacheMetadatosSesion
/// hit? else readAudioMetadataBatch`): resolves cache hits locally without
/// a channel round trip, batches ONLY the cache misses through
/// [FuenteMusicaLocalAuto.metadatosDe], and stores every freshly-resolved
/// entry back into the cache before returning the combined map.
Future<Map<String, MetadatosPista>> _metadatosDeConCache(
List<String> documentIds, {
required FuenteMusicaLocalAuto fuente,
}) async {
if (documentIds.isEmpty) return const {};
final resultado = <String, MetadatosPista>{};
final faltantes = <String>[];
for (final id in documentIds) {
final cacheado = _cacheMetadatosLocal.obtener(id);
if (cacheado != null) {
resultado[id] = cacheado;
} else {
faltantes.add(id);
}
}
if (faltantes.isNotEmpty) {
final resueltos = await fuente.metadatosDe(faltantes);
resueltos.forEach((id, metadatos) {
_cacheMetadatosLocal.guardar(id, metadatos);
resultado[id] = metadatos;
});
}
return resultado;
}
Future<List<MediaItem>?> hijosMusicaLocal(
String parentMediaId, {
required FuenteMusicaLocalAuto? fuente,
}) async {
final constructor = ConstructorArbolAuto();
// Sort-mode and bucket views (Design ADR-4, Phase 2) are routed FIRST —
// routing order is irrelevant to correctness (every prefix in this file
// is collision-free, see each prefix's doc comment), but checking the
// more specific new prefixes first keeps this dispatch readable.
if (constructor.esCarpetaLocalOrdMediaId(parentMediaId)) {
final (modo, documentId, pagina) = constructor.ordenLocalDesde(
parentMediaId,
);
if (fuente == null) return const [];
try {
final nodos = await fuente.hijos(documentId);
if (modo != 'calidad') return const [];
return await constructor.itemsLocalesOrdenCalidad(
nodos,
documentIdPadre: documentId,
pagina: pagina,
metadatosDe: (ids) => _metadatosDeConCache(ids, fuente: fuente),
);
} catch (_) {
return const [];
}
}
if (constructor.esCarpetaLocalBucketMediaId(parentMediaId)) {
final (idxBucket, documentId, pagina) = constructor.bucketLocalDesde(
parentMediaId,
);
if (fuente == null) return const [];
try {
final nodos = await fuente.hijos(documentId);
return await constructor.itemsLocalesBucket(
nodos,
documentIdPadre: documentId,
idxBucket: idxBucket,
pagina: pagina,
metadatosDe: (ids) => _metadatosDeConCache(ids, fuente: fuente),
);
} catch (_) {
return const [];
}
}
final String documentId;
var pagina = 0;
if (parentMediaId == ConstructorArbolAuto.idMusicaLocal) {
@@ -547,10 +973,11 @@ Future<List<MediaItem>?> hijosMusicaLocal(
if (fuente == null) return const [];
try {
final nodos = await fuente.hijos(documentId);
return constructor.itemsLocales(
return await constructor.itemsLocales(
nodos,
documentIdPadre: documentId,
pagina: pagina,
metadatosDe: (ids) => _metadatosDeConCache(ids, fuente: fuente),
);
} catch (_) {
return const [];
@@ -0,0 +1,120 @@
# Apply Progress: Android Auto Local Music — Phase 2 (Metadata, Sort, Name Buckets)
**Change**: android-auto-local-music-phase2
**Mode**: Strict TDD (all behavior-changing Dart) + Static-review-only (native Kotlin, per established project precedent — no Android build/DHU available in this environment)
**Delivery**: Single PR with `size:exception` (resolved delivery strategy — user's established preference this session, matching the two prior high-risk changes today). All tasks implemented in one apply batch; no slicing.
**Batch**: First and only apply batch — no prior apply-progress existed.
## Completed Tasks
All 37/37 checkbox items in `tasks.md` are marked `[x]`. Summary by phase:
### Phase 1 — Foundation: Model & Native Metadata Surface (1.11.8)
- [x] 1.11.2 `MetadatosPista` DTO (all-nullable: `titulo`, `artista`, `artUri`, `bitrate`, `sampleRate`) in `lib/modelos/pista_local.dart`, RED/GREEN via `test/modelos/pista_local_test.dart`.
- [x] 1.3 Extended `PistaLocal` with `artista`, `embeddedArtUri`, `bitrate`, `sampleRate` (nullable, default `null`); stale "Phase 1 minimal shape" doc note removed.
- [x] 1.41.7 Native `readAudioMetadataBatch` channel case + `extraerMetadatosPista` (per-docId `MediaMetadataRetriever` extract, API-31-gated sample rate) + `cachearArteEmbebido` (FileProvider cache write, skip-if-exists) + `trimArtCache` (mtime-LRU, 256 files / 32 MB) in `MainActivity.kt`. **Static review only** — no build/DHU in this environment, same precedent as `listAudioChildren`/`resolvePlayableUri`/`pickMusicFolder`.
- [x] 1.8 Flagged in this report (see "Native Kotlin — Static Review Only" section below).
### Phase 2 — Metadata Cache & Async `itemsLocales` Conversion (load-bearing) (2.12.12)
- [x] 2.12.2 `CacheMetadatosSesion` (flat `LinkedHashMap`, 256-entry LRU-by-access) in `lib/servicios/musica_local_auto.dart`.
- [x] 2.32.4 `FuenteMusicaLocalAuto.metadatosDe(List<String>)` interface method + `FuenteMusicaLocalAutoImpl` channel-backed implementation (never throws, empty-input short-circuit, null-field-tolerant row mapping).
- [x] 2.5 **THE load-bearing test**: metadata-resolution spy proving page 0 of a 200-node folder resolves EXACTLY the 50 page docIds (not all 200), page 3 resolves exactly the trailing 50 — mirrors the existing `construirItem` call-count invariant test exactly.
- [x] 2.62.7 Metadata-present case (title/artUri/artist reflect resolved metadata) and metadata-absent/failed case (falls back to filename + placeholder, no exception) — both tested.
- [x] 2.82.9 `itemsLocales` converted to `Future<List<MediaItem>>`; slice-BEFORE-metadata-fetch-BEFORE-build ordering preserved exactly; `_itemLocal` now builds title/artist/artUri/displaySubtitle from resolved `MetadatosPista` with Phase-1 fallback when absent/blank.
- [x] 2.10 `hijosMusicaLocal` call site: `await constructor.itemsLocales(...)`.
- [x] 2.11 Regression: every existing `itemsLocales` call site in `navegacion_auto_test.dart` updated to `await` + supplied a `metadatosDe` function (18 call sites — grep-confirmed single production caller `hijosMusicaLocal`, already `async`, already `await`s).
- [x] 2.12 Phase 1 scenarios (empty subfolder, folder browse, playback resolution, art fallback) and the paging spy test confirmed still green under the new async signature.
### Phase 3 — Quality Sort, Name Buckets, Media-ID Wiring (3.13.12)
- [x] 3.13.2 `compararCalidadLocal`/`ordenarPorCalidadLocal` (bitrate-desc comparator, unknown-bitrate-sorts-last, mirrors `OrdenEmisoras.calidad`'s shape).
- [x] 3.33.4 `_maxPistasParaOrdenCalidad = 150` boundary (149/150 present, 151 omitted) + `itemsLocalesOrdenCalidad` (full-folder batched `metadatosDe`, sort, then `paginaDe`).
- [x] 3.53.6 `bucketsDe(List<NodoLocal>)` — pure, name-only partitioning into 4 fixed ranges (A-F/G-M/N-S/T-Z); a bucket with 0 matches returns `[]`, not an error; structurally cannot call `metadatosDe` (signature doesn't receive one).
- [x] 3.73.9 `carpeta_local_ord:<modo>:<pagina>:<docId>` / `carpeta_local_bucket:<idxBucket>:<pagina>:<docId>` prefixes + `esCarpetaLocalOrdMediaId`/`esCarpetaLocalBucketMediaId` + `ordenLocalDesde`/`bucketLocalDesde` decoders (split-on-first-two-colons chain); collision guards tested against all 6 pre-existing prefixes.
- [x] 3.103.11 Page-0 mode/bucket prepend wired directly into `itemsLocales` (quality entry when `0 < totalPistas <= 150`; 4 bucket entries when `totalPistas > 50`; page > 0 never re-prepends). Hardcoded Spanish labels (`'Ordenar por calidad'`, `'A-F'`/`'G-M'`/`'N-S'`/`'T-Z'`) — **no `.arb` files touched**, per the established car-tree-label precedent.
- [x] 3.12 New `_ord`/`_bucket` media ids routed through `hijosMusicaLocal`'s dispatch alongside the existing `carpeta_local:`/`carpeta_local_pag:` branches.
### Phase 4 — Art Fallback & Final Regression (4.14.5)
- [x] 4.1 Art-fallback matrix: cache-miss (`artUri: null`) → placeholder; parse-failure (all-null `MetadatosPista`) → placeholder; never an empty/broken tile — dedicated test group added.
- [x] 4.2 Verification-only: `_itemLocal`'s Task-2.9 fallback branch already covers 4.1 — no gap found, no new production code needed.
- [x] 4.3 Full regression: `navegacion_auto_test.dart` + `musica_local_auto_test.dart` + `pista_local_test.dart` run together — 137/137 pass (see "Test Results" below).
- [x] 4.4 **Deviated/manual-follow-up** (flagged, not executed here): `flutter analyze`, `flutter test --coverage`, `flutter gen-l10n` — no l10n gap was found (no new phone-facing string introduced), so `gen-l10n` is moot; `analyze`/`coverage` deferred to manual pre-merge step per this task's own instruction and the orchestrator's constraint (no `flutter analyze`/build in this environment).
- [x] 4.5 **Deviated/manual-follow-up** (flagged, not executed here): on-device hardware validation of `_maxPistasParaOrdenCalidad = 150` and the 256-file/32 MB art budget — no DHU/emulator available in this environment (Design "Open Questions", explicitly out of this task list's automated scope).
## Files Changed
| File | Action | What Was Done |
|------|--------|---------------|
| `lib/modelos/pista_local.dart` | Modified | Added `MetadatosPista` DTO; extended `PistaLocal` with `artista`/`embeddedArtUri`/`bitrate`/`sampleRate` |
| `test/modelos/pista_local_test.dart` | Created | 5 tests for `MetadatosPista` + extended `PistaLocal` construction |
| `lib/servicios/musica_local_auto.dart` | Modified | Added `CacheMetadatosSesion` (LRU-by-access, 256 entries); added `metadatosDe` to `FuenteMusicaLocalAuto` interface + channel-backed impl |
| `test/servicios/musica_local_auto_test.dart` | Modified | Added `CacheMetadatosSesion` group (4 tests) + `FuenteMusicaLocalAutoImpl.metadatosDe` group (4 tests) |
| `lib/servicios/navegacion_auto.dart` | Modified | Async `itemsLocales` + metadata-backed `_itemLocal` (title/artist/artUri/subtitle); `subtituloCalidadLocal`; `compararCalidadLocal`/`ordenarPorCalidadLocal`; `BucketLocal`/`bucketsDe`; `_ord`/`_bucket` prefixes + codec; `itemsLocalesOrdenCalidad`/`itemsLocalesBucket`; page-0 mode/bucket prepend; `hijosMusicaLocal` routing extended; module-level `_cacheMetadatosLocal` + `_metadatosDeConCache` cache wrapper |
| `test/servicios/navegacion_auto_test.dart` | Modified | Extensive: async conversion of all 18 pre-existing `itemsLocales` call sites; new load-bearing metadata-spy test; metadata-present/absent tests; quality-comparator, threshold, bucket-partitioning, media-id codec, collision-guard, page-0-prepend, `itemsLocalesOrdenCalidad`, `itemsLocalesBucket`, `hijosMusicaLocal` `_ord`/`_bucket` routing, and art-fallback-matrix test groups; `_FakeFuenteMusicaLocalAuto` extended with `metadatosDe` |
| `android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt` | Modified | `readAudioMetadataBatch` channel case + `extraerMetadatosPista` + `cachearArteEmbebido` + `trimArtCache` + `hashDocumentId`. **Static review only.** |
| `openspec/changes/android-auto-local-music-phase2/tasks.md` | Modified | All 37 items marked `[x]`; Review Workload Forecast updated with resolved delivery strategy |
`lib/l10n/*.arb` (13 files): **NOT touched.** No genuinely new phone-facing string was introduced — sort-mode/bucket labels are hardcoded Spanish car-tree text, per the corrected grounding note (overrides design.md's File Changes table, which listed `.arb` changes; the corrected instruction for this apply batch takes precedence and was verified against live code: `_tituloMasLocal`/`_carpeta(idFavoritos, ...)` precedent confirmed in `navegacion_auto.dart`, and `localMusicFolderGenericName` confirmed phone-settings-only in `pantalla_ajustes.dart:366`).
## TDD Cycle Evidence
| Task | RED | GREEN | REFACTOR |
|------|-----|-------|----------|
| 1.11.2 `MetadatosPista` | `pista_local_test.dart` written first, ran against pre-change model — compile error confirmed | DTO added, 5/5 pass | Doc comments only |
| 1.3 `PistaLocal` extension | Same file/run as above | Extended fields added, verified green | — |
| 2.12.2 `CacheMetadatosSesion` | Test written first — `Method not found` confirmed | Implemented, 4/4 pass | — |
| 2.32.4 `metadatosDe` (impl) | Test written first — `metadatosDe isn't defined` confirmed | Implemented, 4/4 pass | — |
| 2.5 Metadata-resolution spy (load-bearing) | Test written first against sync `itemsLocales` — compile error confirmed | Async conversion + docId-slicing implemented, verified page-0/page-3 exact-match | — |
| 2.62.9 Metadata-present/absent + `_itemLocal` | Tests written first | `_itemLocal` metadata-aware rewrite, verified | Added `subtituloCalidadLocal`/`_formatKhz` for completeness (design ADR-5 discipline) beyond the letter of the numbered task, lightly tested in the metadata-present case |
| 2.102.12 `hijosMusicaLocal` await + regression | 18 pre-existing call sites updated to `await` first (compile errors confirmed), then GREEN | All 137 tests pass | — |
| 3.13.2 Quality comparator | Test written first — `Method not found` confirmed | Implemented, 2/2 pass | — |
| 3.33.4 `_maxPistasParaOrdenCalidad` + `itemsLocalesOrdenCalidad` | Tests written first | Implemented, verified boundary (149/150/151) | — |
| 3.53.6 `bucketsDe` | Tests written first | Implemented, 4/4 pass incl. structural no-metadata-call proof | — |
| 3.73.9 Media-id codec | Tests written first | Implemented, collision guards pass | — |
| 3.103.12 Page-0 wiring + `hijosMusicaLocal` routing | Tests written first | Implemented; **8 pre-existing single-node `itemsLocales` fixtures broke** (corrected during verify from an initially-reported 9) because they used `.single`/exact-length assertions that didn't anticipate the new prepend — fixed by scoping assertions to `pista:`-prefixed items (documented as expected regression-test maintenance, not a design deviation) | — |
| 4.1 Art-fallback matrix | Test written first | Verified via existing fallback branch, 2/2 pass | — |
## Deviations from Design
1. **Cache-wiring location** (inferred, not explicitly specified in `design.md`): `CacheMetadatosSesion` is instantiated as a module-level singleton (`_cacheMetadatosLocal`) in `navegacion_auto.dart`, wrapped by `_metadatosDeConCache`, and injected into `hijosMusicaLocal`'s calls to `itemsLocales`/`itemsLocalesOrdenCalidad`/`itemsLocalesBucket`. This mirrors the existing `_fuenteMusicaLocalGlobal` singleton pattern in `servicio_audio.dart` and satisfies ADR-2's "paging a large folder must not evict an earlier page's cached metadata" requirement (the cache must outlive a single `getChildren` call). Design's "Data Flow" section describes the cache-then-native flow conceptually but doesn't specify exact ownership — this is a reasonable, minimal-footprint resolution, not a functional deviation.
2. **Local-track `displaySubtitle`** (design ADR-5's "unknown → omit" discipline + Testing Strategy table list it, but there is no discrete numbered task for it): implemented `subtituloCalidadLocal`/`_formatKhz` (kbps · kHz format) for completeness and design fidelity, with light test coverage folded into the metadata-present test rather than a full matrix (kept scope proportionate to the fact that it wasn't its own numbered RED/GREEN task pair).
3. **`ofreceOrdenCalidad` lower bound**: implemented as `0 < totalPistas <= 150` (any non-empty folder up to the cap gets the quality entry, including a 1-track folder) — this is the literal reading of ADR-4 ("IF audio count ≤ 150", no stated lower bound beyond non-empty). This caused 8 pre-existing single-node test fixtures to need updating (see TDD Evidence row 3.103.12) since they now receive an extra prepended mode entry — corrected by scoping those assertions, not by weakening the new behavior.
4. **Bucket letter ranges**: fixed as 4 ranges (A-F/G-M/N-S/T-Z) since neither spec nor design specifies exact boundaries beyond "e.g. A-F, G-M, ..." — a name not starting with an ASCII letter matches no bucket (no catch-all "other" bucket defined by spec).
## Issues Found
None blocking. One design-claim was explicitly re-verified against live code per the grounding instructions rather than assumed: the `${applicationId}.fileprovider` `FileProvider` authority and `pluriwave_file_paths.xml`'s `<cache-path path="."/>` declaration were both confirmed present in `AndroidManifest.xml` (lines 97105) and `pluriwave_file_paths.xml` before writing `cachearArteEmbebido` — zero manifest changes were needed, as design claimed.
## Native Kotlin — Static Review Only
Tasks 1.41.8 (`readAudioMetadataBatch`, `extraerMetadatosPista`, `cachearArteEmbebido`, `trimArtCache`, `hashDocumentId` in `MainActivity.kt`) are **static-review-only**, per this project's established precedent (mirrors the review treatment of `listAudioChildren`/`resolvePlayableUri`/`pickMusicFolder` from Phase 1) — no Android build or DHU is available in this environment, so these were implemented carefully with per-entry and whole-call try/catch, `retriever.release()` in `finally`, and never-throws-across-the-channel-boundary discipline, but could not be exercised by an automated test. No test was fabricated for this code that can't actually run.
## Test Results (independently re-confirmed via a second full run before writing this report)
```
flutter test test/servicios/navegacion_auto_test.dart test/servicios/musica_local_auto_test.dart test/modelos/pista_local_test.dart --concurrency=1 --timeout=60s
→ 137/137 passed, 0 failed
- navegacion_auto_test.dart: 114/114
- musica_local_auto_test.dart: 19/19
- pista_local_test.dart: 5/5
```
## Diff Size
`git diff --stat` (tracked files): 6 files changed, 1809 insertions(+), 82 deletions(-).
Plus 1 new untracked file: `test/modelos/pista_local_test.dart` (88 lines).
**Total ≈ 1979 changed lines** — well above the 400-line review budget, as forecast (`400-line budget risk: High`). Delivered as a single PR under `size:exception` per the resolved delivery strategy for this session (matches the same choice made for the two prior high-risk changes today).
## Workload / PR Boundary
- Mode: single PR, `size:exception`
- Current work unit: N/A (not chained)
- Boundary: this batch starts from an empty apply-progress (no prior batch) and finishes with all 37/37 tasks complete, 137/137 tests green
- Estimated review budget impact: High — reviewer should expect a large, multi-concern diff (native Kotlin static review + 3 layered Dart features: async metadata conversion, quality sort, name buckets) explicitly accepted via `size:exception`
## Remaining Tasks
None — all 37/37 tasks complete. Tasks 4.4/4.5 are explicitly deviated/manual-follow-up per their own task description (not part of this task list's automated scope) and are flagged above, not silently skipped.
## Status
37/37 tasks complete. 137/137 tests passing. Ready for verify.
@@ -0,0 +1,122 @@
# Design: Android Auto Local Music — Phase 2 (Metadata, Sort, Name Buckets)
## Technical Approach
Compose real metadata onto the Phase 1 lazy-paging browse tree WITHOUT regressing the
`itemsLocales` **slice-cheap-then-map** invariant. Raw SAF enumeration (`hijos`
`NodoLocal` list) stays cheap and eager-free. Metadata (expensive, async) is resolved
via ONE new batched native call on the existing `pluriwave/file_actions` channel
(`readAudioMetadataBatch`, `MediaMetadataRetriever`), and ONLY for the exact docIds of
the page being returned — mirroring how art/MediaItem construction is already page-scoped.
All real logic (LRU cache eviction, quality comparator, bucket partitioning, media-id
codec) lives in pure Dart; native surface stays a thin per-file extract-and-return loop.
## Architecture Decisions
### ADR-1: Embedded-art delivery via existing FileProvider cache
**Choice**: Native writes `getEmbeddedPicture()` bytes to `cacheDir/pluriwave_art/<hash(docId)>` and returns a `content://${applicationId}.fileprovider/cache/pluriwave_art/<hash>` URI (the manifest ALREADY declares `<cache-path path="."/>` under authority `${applicationId}.fileprovider` — zero new native/manifest surface). `MediaItem.artUri` gets that URI.
**Alternatives**: base64 data-URI (Auto art loader won't fetch it); a second art-only channel call at render time (extra round trip); a custom ContentProvider (new surface).
**Rationale**: reuses the proven FileProvider path (`openDirectory`/`viewDirectory` precedent). Cache key = `hash(documentId)` (docId contains `:`/`/`, illegal in filenames); stable so re-parsing the same track reuses the file (`if (file.exists()) skip extract`). **Eviction**: after each write, native trims the art subdir by lastModified while `count > 256` OR `bytes > 32MB` (LRU-by-mtime) — the ONE unavoidable native-side eviction, kept to a trivial reviewable loop because the files are native-owned and round-tripping names to Dart to pick deletions adds channel chatter for no testability gain (the `delete()` is native regardless). **Cache-miss at render**: art URI is resolved at MediaItem-build time inside the same `getChildren` call, so the file exists when the item ships; no embedded picture → native returns `artUri: null` → Dart falls back to Phase 1's `artUriLocal(documentId)` placeholder rotation. Never crashes.
### ADR-2: Parsed-metadata session cache — pure-Dart flat LRU
**Choice**: `CacheMetadatosSesion` — an in-memory `LinkedHashMap<String docId, MetadatosPista>` bounded to **256 entries**, LRU by access order, pure Dart, unit-tested. In-memory only (dies with the process → always fresh, no persistence staleness).
**Alternatives**: folder-scoped cache cleared on navigate-away (thrashes when paging a huge folder); unbounded (OOM risk on large libraries); persisted store (staleness, the proposal rejected eager-scan for this reason).
**Rationale**: 256 ≈ 5 pages of 50 → paging page 2 does NOT evict page 1; leaving and re-entering a recently-seen folder stays warm. A flat LRU keeps the natural working set without folder-boundary thrash. Batch resolution consults the cache first; only misses hit native.
### ADR-3: Quality-sort — threshold-capped, batched, blocking parse-then-sort-then-page
**Choice**: Quality-sort resolves bitrate for EVERY audio file in the folder via a SINGLE batched `readAudioMetadataBatch` call, sorts desc (mirroring `ordenarEmisoras(..., calidad)`), caches results, then applies the existing paging. Offered ONLY when the folder's audio-file count ≤ `_maxPistasParaOrdenCalidad` (**150**); above that the quality entry is omitted (name-sort + buckets only).
**Alternatives**: background-prefetch + progressive-reveal (legacy `MediaBrowserService` can't stream partial nor re-sort in place — proposal already notes this); no cap (hundreds of `MMR.setDataSource` calls ≈ many seconds → head-unit "content not loading").
**Rationale**: `onLoadChildren` has a de-facto "be snappy" expectation; 150 files × ~30-50ms batched ≈ worst-case ~5-7s paid ONCE (cached for re-paging), only when the user opts into the quality entry. The cap is the sane safety valve; buckets cover big folders instead.
### ADR-4: Browse-tree shape — mode entries prepended on page 0
**Choice**: On page 0 of a local folder, prepend non-playable "view" folders BEFORE the default name-sorted track list (same precedent as `carpetasFavoritos` prepending group folders before stations): (1) "Ordenar por calidad" IF audio count ≤ 150; (2) alphabetical bucket folders IF track count > 50 (buckets add no value for small folders). Name-sort stays the DEFAULT view (no explicit "name" entry — Auto's back button returns from any sub-view). New media-id families:
| Family | Format | View |
|--------|--------|------|
| `carpeta_local_ord:` | `carpeta_local_ord:<modo>:<pagina>:<docId>` | sorted (`modo` = `calidad`) |
| `carpeta_local_bucket:` | `carpeta_local_bucket:<idxBucket>:<pagina>:<docId>` | name-bucket slice |
**Alternatives**: sibling entries mixed into the track page (clutter, paging collisions); a wrapping "Ver/Ordenar" intermediate folder (extra tap for the common case).
**Rationale**: **Collision-free** — both diverge from `carpeta_local:` at index 13 (`:` vs `_`) and from each other/`carpeta_local_pag:` at the char after `carpeta_local_` (`o`/`b`/`p`), so no `startsWith` false-match (same proof the existing `_pag` prefix documents); routing order is irrelevant. Fixed-arity fields (`modo`/`idxBucket`, then `pagina`) precede the free-form docId, decoded by the proven **split-on-first-colon** chain from `paginaCarpetaLocalDesde` — a docId containing `:`/`/` survives verbatim. Buckets are **name-only** (partition the already-cheap name-sorted list → NO metadata) so they compose with slice-cheap-then-map untouched; only `modo=calidad` pays the metadata cost.
### ADR-5: MMR API-level degradation
**Choice**: `METADATA_KEY_SAMPLERATE` (key 38) is API 31+; guard with `Build.VERSION.SDK_INT >= 31`, else `sampleRate = null`. `METADATA_KEY_BITRATE`, `_TITLE`, `_ARTIST`, `getEmbeddedPicture()` are all ≥ API 10 → always read. Missing field → null, degraded gracefully (subtitle omits the kHz fragment), reusing Phase 1's "unknown → omit" subtitle discipline.
**Rationale**: only sample-rate needs gating; everything else the proposal wants is universally available.
## Data Flow
getChildren(carpeta_local[_ord|_bucket]:...:docId)
│ decode view+page+docId (pure Dart codec)
fuente.hijos(docId) ── cheap NodoLocal[] (unchanged, no metadata)
├─ name (default) : sort by nombre ─┐
├─ bucket:<i> : name-sort → filter bucket ─┤ CHEAP, no metadata
└─ ord:calidad : batch-parse ALL (≤150, │
cache) → sort bitrate desc ┘ metadata for sort key only
paginaDe(...) ── slice page (cheap)
metadatosDe(slice.trackDocIds) ── CacheMetadatosSesion hit? else
│ readAudioMetadataBatch (native, page-scoped)
build MediaItem per node: titulo/artista/artUri from meta, filename/placeholder fallback
append "Más…" if hayPaginaSiguiente
## File Changes
| File | Action | Description |
|------|--------|-------------|
| `android/.../MainActivity.kt` | Modify | `readAudioMetadataBatch` case + `MediaMetadataRetriever` extract loop + art-file write/trim (static-review-only, thin) |
| `lib/modelos/pista_local.dart` | Modify | Add `MetadatosPista` DTO (titulo/artista/bitrate/sampleRate/artUri); extend `PistaLocal` with same fields |
| `lib/servicios/musica_local_auto.dart` | Modify | `metadatosDe(docIds)` on `FuenteMusicaLocalAuto` + channel call; `CacheMetadatosSesion` |
| `lib/servicios/navegacion_auto.dart` | Modify | mode/bucket prefixes + codec, `bucketsDe`, quality comparator, metadata-backed `itemsLocales`, subtitle |
| `lib/estado/orden_emisoras.dart` | (reuse) | `OrdenEmisoras.calidad` comparator shape mirrored for local tracks |
| `lib/l10n/*.arb` (13) | Modify | Sort-mode + bucket + "unknown metadata" labels |
## Interfaces / Contracts
Native (`pluriwave/file_actions`), never throws across the boundary:
readAudioMetadataBatch(treeUri: String, documentIds: List<String>) -> List<Map>
// one map per requested docId, in order; fields null when absent/unparseable
{ documentId: String, titulo: String?, artista: String?,
bitrate: Int?/*bps*/, sampleRate: Int?/*API31+ else null*/, artUri: String?/*content://*/ }
Per-file try/catch → all-null entry (docId echoed); whole call try/catch → `[]`;
`MediaMetadataRetriever.release()` in `finally`.
Dart:
class MetadatosPista { final String? titulo, artista, artUri; final int? bitrate, sampleRate; }
abstract FuenteMusicaLocalAuto {
Future<Map<String, MetadatosPista>> metadatosDe(List<String> documentIds); // batched, never throws
}
class CacheMetadatosSesion { MetadatosPista? obtener(String); void guardar(String, MetadatosPista); } // LRU 256
## Testing Strategy
| Layer | What | Approach |
|-------|------|----------|
| Unit | `CacheMetadatosSesion` LRU eviction/order | pure Dart |
| Unit | media-id encode/decode (`_ord`/`_bucket`, docId with `:`/`/`), collision guards | pure Dart |
| Unit | `bucketsDe` partitioning + labels; quality comparator | pure Dart |
| Unit | `itemsLocales` metadata-backed build: resolves ONLY sliced page's docIds (spy call-count invariant) + fallbacks | pure Dart, injected metadata map |
| Unit | subtitle format (bitrate/kHz known/unknown, no literal "null") | pure Dart |
| Static review | `readAudioMetadataBatch`, art write/trim, API-31 sample-rate guard | Kotlin review (no build/DHU) |
## Migration / Rollout
No migration. Purely additive over Phase 1. Rollback = remove `readAudioMetadataBatch`,
drop `_ord`/`_bucket` prefixes + `metadatosDe`, restore filename title + placeholder art.
Phase 1 browse/play/paging untouched.
## Open Questions
- [ ] `_maxPistasParaOrdenCalidad = 150` and art budget (256 files / 32 MB) are first-pass; validate on-device in a later hardware pass (no DHU here).
@@ -0,0 +1,67 @@
# Proposal: Android Auto Local Music — Phase 2 (Real Metadata, Sort, Name Buckets)
## Intent
Phase 1 shipped browsable SAF folders with filename-only titles and generic placeholder art. Phase 2 delivers the deferred polish: real embedded metadata (title/artist/bitrate/sample-rate/album art), sort by name and audio quality, and name-based navigation — without breaking Phase 1's lazy paging philosophy or its pure-SAF, zero-dangerous-permission stance.
## Scope
### In Scope
- **A. Real metadata** — resolve embedded title/artist/bitrate/sample-rate + embedded album art for tracks on the page being shown/played, replacing filename title and `station_art_*` placeholder.
- **B. Sort** — name (filename, already cheap) and quality (bitrate desc, reusing the `ordenarEmisoras(..., OrdenEmisoras.calidad)` comparator shape) exposed as separate navigable sort-mode entries (Android Auto cannot re-sort in place).
- **C. Name navigation** — alphabetical navigable buckets (AF, etc.) as the realistic, browse-tree-native form of "filter by name".
- New translatable strings (sort-mode labels, "unknown metadata" text) added to ALL 13 ARB files from the start.
### Out of Scope
- Phase 3: subfolder scoping, shuffle, transport-control polish.
- Live text-search box (impossible under legacy MediaBrowserService).
- Voice search via `audio_service.onSearch` — flagged as Phase 3 candidate.
## Capabilities
### New Capabilities
- None.
### Modified Capabilities
- `local-music-browse`: gains metadata resolution, quality sort, alphabetical buckets (confirm exact spec name in `openspec/specs/` during sdd-spec).
- `android-auto-navigation`: local branch gains sort-mode + bucket entries.
## Approach — Metadata Extraction Decision (the one open technical call)
**DECISION: native `android.media.MediaMetadataRetriever` on the existing `pluriwave/file_actions` channel. No new pub dependency.**
Why, versus the Dart packages:
- Phase 1 tracks are `content://` SAF URIs (`buildDocumentUriUsingTree`), NOT file paths and NOT guaranteed MediaStore-indexed. `on_audio_query` is MediaStore-based and would (a) miss files outside standard media folders reached via an arbitrary SAF tree and (b) reintroduce `READ_MEDIA_AUDIO`, the dangerous permission Phase 1 deliberately rejected — REJECT.
- `audio_metadata_reader` / `flutter_media_metadata` need a `File`/bytes; SAF `content://` gives neither, so we'd still need native byte-streaming — more complexity, no less native code.
- `MediaMetadataRetriever.setDataSource(context, uri)` accepts a `content://` URI directly, returns TITLE/ARTIST/BITRATE + `getEmbeddedPicture()` bytes, reuses the existing `contentResolver`, adds zero deps and zero permissions. Consistent with the Phase 1 hand-rolled-channel ADR.
**DECISION: parse-on-demand + bounded in-memory session cache. NOT eager-scan-and-cache** — eager scan is slow, needs a persistence store, and goes stale when the user adds/removes files. On-demand is always fresh and matches paging. Design formalizes cache bounds/eviction.
Tension to formalize in sdd-design: **sort-by-quality needs bitrate for the whole folder**, so it must resolve metadata for all tracks in that folder (not just the visible page). Mitigation: name sort stays metadata-free (filename only); quality-sort cost is paid only when the user opts into that entry.
## Affected Areas
| Area | Impact | Description |
|------|--------|-------------|
| `MainActivity.kt` (`file_actions`) | Modified | New `readAudioMetadata(treeUri, documentId)` via MediaMetadataRetriever; embedded-art delivery (cache file + existing FileProvider) — static-review-only |
| `lib/modelos/pista_local.dart` | Modified | Add artist/bitrate/sampleRate/artUri fields |
| `lib/servicios/musica_local_auto.dart` | Modified | Metadata channel call + session cache |
| `lib/servicios/navegacion_auto.dart` | Modified | Metadata-backed title/subtitle/art; sort-mode + bucket entries |
| `lib/l10n/*.arb` (13) | Modified | Sort/metadata labels |
## Risks
| Risk | Likelihood | Mitigation |
|------|------------|------------|
| MMR sample-rate key is API 31+ | High | Degrade gracefully, reuse existing "unknown" handling |
| `artUri` needs a static URI, not bytes | High | Persist embedded art to cache dir, serve via existing FileProvider |
| Native untestable here (no build/DHU) | High | Static-review-only; keep sort/bucket/mapping logic pure Dart |
| Quality-sort forces full-folder parse | Med | Opt-in cost only when that entry is chosen |
| ARB drift across 13 locales | Med | All 13 in tasks from the start |
## Rollback Plan
Additive over Phase 1. Revert by removing `readAudioMetadata`, restoring filename title + placeholder art, and dropping sort-mode/bucket entries. Phase 1 browse/play untouched.
## Dependencies
- None new (native MMR, no pub package).
## Success Criteria
- [ ] Tracks show embedded title/artist and real album art when present, filename/placeholder fallback otherwise.
- [ ] Name and quality sort available as navigable entries; quality orders by bitrate desc.
- [ ] Alphabetical buckets navigable within a folder.
- [ ] Metadata resolved on-demand only; no eager scan, no persistence staleness.
- [ ] All 13 ARB files carry new strings; no regression to Phase 1 or stations; pure Dart unit-tested, native static-reviewed.
@@ -0,0 +1,98 @@
# Delta for Android Auto Media
## MODIFIED Requirements
### Requirement: Local Music Browsable Tree
The Android Auto browse tree MUST expose a new non-playable local-music root folder alongside the existing station folders. Browsing that root and any nested subfolder MUST recursively mirror the picked SAF folder's structure: subfolders as non-playable `carpeta_local:<id>` items and audio files as playable `pista:<id>` items resolving to `PistaLocal` instances. When embedded metadata (title and/or album art) can be resolved for a track on the requested page, the displayed title and art MUST reflect that metadata instead of the raw filename and generic placeholder art. When metadata is unavailable, unparseable, or resolution otherwise fails, the item MUST fall back to the raw filename as title and the existing generic placeholder art, exactly as in Phase 1.
(Previously: always used the raw filename as the displayed title, with no metadata resolution.)
#### Scenario: Car browses the local-music root
- GIVEN a local music root folder was picked and its permission is valid
- WHEN `getChildren` is called with the local-music root folder id
- THEN it returns the root's immediate subfolders as `carpeta_local:<id>` items and audio files as `pista:<id>` playable items
- AND each playable item's title reflects resolved metadata when available, else the file's raw filename
#### Scenario: Car browses a nested subfolder
- GIVEN a `carpeta_local:<id>` folder returned from a prior browse call
- WHEN `getChildren` is called with that folder id
- THEN it returns that subfolder's own contents (nested folders and/or tracks), recursively mirroring the on-device structure
- AND browsing an empty subfolder returns an empty list, not an error
#### Scenario: Track has embedded metadata
- GIVEN an audio file on the requested page has parseable embedded title and/or album art
- WHEN it is mapped to a playable `pista:<id>` `MediaItem`
- THEN its `title` and `artUri` reflect the resolved metadata, not the raw filename or generic placeholder
#### Scenario: Metadata unavailable or resolution fails (graceful fallback)
- GIVEN an audio file's embedded metadata is absent, corrupt, in an unsupported format, or blocked by a permission edge case
- WHEN it is mapped to a playable `pista:<id>` `MediaItem`
- THEN its title falls back to the raw filename and its art falls back to the generic placeholder, exactly as in Phase 1
- AND no exception propagates from metadata resolution, and the rest of that page's items are still returned
## ADDED Requirements
### Requirement: Local Music Sort Mode Navigation
The local-music browse tree MUST expose sort mode as navigable, non-playable browse-tree entries (at least "by name" and "by audio quality") for a folder's tracks, since the underlying legacy browse surface cannot re-sort an already-rendered folder in place. Selecting a sort-mode entry MUST return that folder's tracks ordered accordingly: name sort orders by filename; quality sort orders by bitrate descending, reusing the existing `ordenarEmisoras(..., OrdenEmisoras.calidad)` comparator shape.
#### Scenario: Sort-mode entries are available for a folder
- GIVEN the user is browsing a local-music folder containing tracks
- WHEN that folder's sort-mode navigation is requested
- THEN at least a "by name" and a "by audio quality" navigable entry are returned
#### Scenario: User selects sort by quality
- GIVEN the user selects the "by audio quality" entry for a folder
- WHEN its children are resolved
- THEN the folder's tracks are returned ordered by bitrate descending
- AND tracks with unknown bitrate are ordered consistently, without throwing
#### Scenario: User selects sort by name
- GIVEN the user selects the "by name" entry for a folder
- WHEN its children are resolved
- THEN the folder's tracks are returned ordered by filename
### Requirement: Local Music Alphabetical Name Buckets
The local-music browse tree MUST expose alphabetical name-bucket entries (e.g. A-F, G-M, ...) as navigable, non-playable folders for a folder's tracks, as the Phase 2 realization of name-based filtering — the underlying legacy `MediaBrowserService` surface does not support a live text-search box.
#### Scenario: User browses name buckets for a folder
- GIVEN a local-music folder contains tracks spanning multiple starting letters
- WHEN the user browses that folder's name-bucket navigation level
- THEN each returned bucket is a non-playable folder covering a contiguous letter range
- AND selecting a bucket returns only the tracks whose name falls within that range
#### Scenario: Bucket with no matching tracks
- GIVEN a name-bucket range that currently matches zero tracks in a folder
- WHEN that bucket is browsed
- THEN it returns an empty list, not an error
### Requirement: Local Track Embedded Album Art Display
A playable `pista:<id>` `MediaItem` MUST display embedded album art via `artUri` when the track's embedded picture can be resolved and served as a static URI. When embedded art is absent or cannot be resolved (per the browse tree's metadata-resolution fallback), the item MUST fall back to Phase 1's existing generic rotating placeholder art (`station_art_*` rotation). The car head unit MUST NOT display an empty or broken art tile for any local track.
#### Scenario: Track has embedded album art
- GIVEN an audio file on the requested page has a resolvable embedded picture
- WHEN it is mapped to a playable `MediaItem`
- THEN `artUri` points at the resolved embedded art, served as a static URI
#### Scenario: Track has no or unresolvable embedded art
- GIVEN an audio file has no embedded picture, or it cannot be parsed or served
- WHEN it is mapped to a playable `MediaItem`
- THEN `artUri` falls back to the same rotating placeholder used in Phase 1
- AND no broken, empty, or indefinitely-loading art tile is shown
## Out of Scope (Phase 2)
The following are explicitly deferred and MUST NOT be treated as Phase 2 requirements: local-folder subfolder-scoping refinements, shuffle playback, live text search, and voice search (`audio_service.onSearch`). These remain candidates for Phase 3 or later.
@@ -0,0 +1,91 @@
# Tasks: Android Auto Local Music — Phase 2 (Metadata, Sort, Name Buckets)
## Review Workload Forecast
| Field | Value |
|-------|-------|
| Estimated changed lines | ~950-1250 (native ~180-230, `pista_local.dart` ~60, `musica_local_auto.dart` ~180-220, `navegacion_auto.dart` ~260-320, new/extended tests ~350-450) |
| 400-line budget risk | High |
| Chained PRs recommended | Yes |
| Suggested split | PR 1 -> PR 2 -> PR 3 (see Suggested Work Units) |
| Delivery strategy | single-pr with size:exception (resolved at apply time — user's established preference this session) |
| Chain strategy | N/A — single PR, not chained |
Decision needed before apply: No — resolved as single-pr + size:exception
Chained PRs recommended: Yes (forecast unchanged; overridden by explicit size:exception)
Chain strategy: N/A
400-line budget risk: High (accepted via size:exception)
Rationale: this is the async-conversion blast radius the design explicitly calls out as
"the main implementation hazard" — `itemsLocales` sync-to-async touches every caller
(`hijosMusicaLocal`, both existing callers already `await` it so the call sites are
compatible, but the signature change ripples through `ConstructorArbolAuto` tests), plus
one native batched-extraction method + LRU cache + quality sort + bucket partitioning +
media-id codec extension, each independently testable but collectively larger than
Phase 1's original browse-tree change.
### Suggested Work Units
| Unit | Goal | Likely PR | Notes |
|------|------|-----------|-------|
| 1 | Model + native metadata surface (Tasks 1-3) | PR 1 | `PistaLocal`/`MetadatosPista` DTOs, `readAudioMetadataBatch` + art cache/LRU trim (static-review-only). Independent — no Dart call sites yet. |
| 2 | Metadata cache + async `itemsLocales` conversion (Tasks 4-5, 10-11) | PR 2 | THE load-bearing unit — base = PR 1 branch. `CacheMetadatosSesion`, `metadatosDe`, async `itemsLocales`, regression suite. Ships metadata-backed titles/art with existing name-sort browse tree; no sort/bucket UI yet. |
| 3 | Sort mode + buckets + media-id prefixes + wiring (Tasks 6-9, 12) | PR 3 | base = PR 2 branch. New `_ord`/`_bucket` prefixes, quality comparator, bucket partitioning, page-0 mode entries. |
## Phase 1: Foundation — Model & Native Metadata Surface
- [x] 1.1 RED: `test/modelos/pista_local_test.dart` (new file) — construct `MetadatosPista` with all fields null; assert no throw, all getters return `null`.
- [x] 1.2 GREEN: `lib/modelos/pista_local.dart` — add `MetadatosPista` DTO (`titulo`, `artista`, `artUri` as `String?`; `bitrate`, `sampleRate` as `int?`), all-nullable const constructor.
- [x] 1.3 GREEN: extend `PistaLocal` (`lib/modelos/pista_local.dart:29-46`) with `artista`, `embeddedArtUri`, `bitrate`, `sampleRate` fields (nullable, default `null`), update doc comment (remove stale "Phase 1 minimal shape" note).
- [x] 1.4 Native (static-review-only, thin): `MainActivity.kt` — add `"readAudioMetadataBatch"` case to the `file_actions` handler (after `"hasPersistedPermission"`, `MainActivity.kt:307-313`), extracting `treeUri: String` + `documentIds: List<String>` args, delegating to a new private `readAudioMetadataBatch(treeUri, documentIds): List<Map<String, Any?>>`.
- [x] 1.5 Native (static-review-only): implement `readAudioMetadataBatch` — per-docId `MediaMetadataRetriever` extract (`METADATA_KEY_TITLE`, `_ARTIST`, `_BITRATE`, `getEmbeddedPicture()`; `METADATA_KEY_SAMPLERATE` gated `Build.VERSION.SDK_INT >= 31` per ADR-5), each entry wrapped in its own try/catch -> all-null-but-`documentId` row on failure, `retriever.release()` in `finally`, whole-call try/catch -> `[]`; never throws across the channel boundary (mirrors `listAudioChildren`/`resolvePlayableUri` shape, `MainActivity.kt:373-426`).
- [x] 1.6 Native (static-review-only): embedded-art cache write — inside the same extract loop, when `getEmbeddedPicture()` is non-null, write bytes to `cacheDir/pluriwave_art/<hash(documentId)>` (skip write if file already exists), return `content://${applicationId}.fileprovider/cache/pluriwave_art/<hash>` (reuses `AndroidManifest.xml:97-102` authority + `pluriwave_file_paths.xml:6-8` `cache-path path="."` — confirmed present, zero manifest changes needed).
- [x] 1.7 Native (static-review-only): after each art write, trim `pluriwave_art/` by `lastModified` (oldest first) while `count > 256 OR totalBytes > 32MB`.
- [x] 1.8 Flag clearly in PR description: Tasks 1.4-1.7 are Kotlin, static-review-only per project precedent (no build/DHU here, mirrors `listAudioChildren`/`resolvePlayableUri`/`pickMusicFolder` review treatment).
## Phase 2: Metadata Cache & Async `itemsLocales` Conversion (load-bearing)
- [x] 2.1 RED: `test/servicios/musica_local_auto_test.dart``CacheMetadatosSesion` group: store 256 entries then a 257th, assert the least-recently-*accessed* entry (not just least-recently-inserted) is evicted; assert `obtener()` on a hit refreshes recency order.
- [x] 2.2 GREEN: `lib/servicios/musica_local_auto.dart` — add `CacheMetadatosSesion` (flat `LinkedHashMap<String, MetadatosPista>`, bound 256, LRU-by-access: `obtener` re-inserts on hit, `guardar` evicts `entries.first.key` when `length > 256` after insert).
- [x] 2.3 RED: `musica_local_auto_test.dart``FuenteMusicaLocalAutoImpl.metadatosDe` group: empty `documentIds` -> `{}` without a channel call; channel throws -> `{}` (never propagates); a native null/missing field in a row -> that key's `MetadatosPista` has the corresponding field `null`, not a crash.
- [x] 2.4 GREEN: `lib/servicios/musica_local_auto.dart` — add `metadatosDe(List<String> documentIds)` to `FuenteMusicaLocalAuto` interface (per design contract) and `FuenteMusicaLocalAutoImpl`: try/catch-wrapped `readAudioMetadataBatch` invocation (same pattern as `hijos`, `musica_local_auto.dart:169-189`), map rows to `Map<String, MetadatosPista>` keyed by echoed `documentId`.
- [x] 2.5 RED: `test/servicios/navegacion_auto_test.dart` — extend the `ConstructorArbolAuto.itemsLocales` group with a metadata-resolution spy test mirroring the existing call-count invariant test (`navegacion_auto_test.dart:493-551`): 200 nodes, a fake `metadatosDe` that records the exact `documentIds` list it received; assert on page 0 it receives EXACTLY the 50 page docIds (not all 200), and page 3 receives exactly the trailing 50 — proves the resolve-only-the-page invariant holds through the async conversion.
- [x] 2.6 RED: `navegacion_auto_test.dart` — metadata-present case: a node whose docId resolves to a `MetadatosPista` with `titulo`/`artUri` set -> built `MediaItem.title`/`artUri` reflect the metadata, not the filename/placeholder.
- [x] 2.7 RED: `navegacion_auto_test.dart` — metadata-absent/failed case: docId not present in the resolved map (or `metadatosDe` returns `{}` entirely) -> `MediaItem.title` falls back to `_tituloDesdeNombre`, `artUri` falls back to `artUriLocal` (exactly Phase 1 behavior) — no exception.
- [x] 2.8 GREEN: `lib/servicios/navegacion_auto.dart` — convert `itemsLocales` (`navegacion_auto.dart:363-378`) to `Future<List<MediaItem>>`: after `paginaDe` slices the page (unchanged, still cheap), call `fuente.metadatosDe(paginaActual.where((n) => !n.esDirectorio).map((n) => n.documentId).toList())` for ONLY the sliced page's track docIds, then map via an async-aware `construirItem` (keep `@visibleForTesting` injection point for the spy test) that consults the resolved map before falling back to filename/placeholder. Preserve the exact `sort -> paginaDe -> map(construir)` ordering (slice BEFORE metadata fetch, metadata fetch BEFORE `MediaItem` build).
- [x] 2.9 GREEN: `lib/servicios/navegacion_auto.dart` — update `_itemLocal` (`navegacion_auto.dart:380-391`) to accept the resolved `Map<String, MetadatosPista>` (or become instance-scoped per call), building title/artUri/subtitle from metadata when present, falling back to Phase 1 logic (`_tituloDesdeNombre`, `artUriLocal`) when absent — folders (`esDirectorio`) are unaffected (no metadata lookup for directories).
- [x] 2.10 GREEN: `lib/servicios/navegacion_auto.dart` — update `hijosMusicaLocal` (`navegacion_auto.dart:529-558`) call site: `await constructor.itemsLocales(...)` (already inside an `async` function and already implicitly compatible since the call wasn't previously awaited — now becomes a real `await`).
- [x] 2.11 Regression: run full `test/servicios/navegacion_auto_test.dart` + `test/servicios/musica_local_auto_test.dart` suites; every existing `itemsLocales`/`construirItem` call site in both test files (Tasks reference: 25+ call sites per `navegacion_auto_test.dart:493-851` grep) must be updated to `await` the now-`Future` call — confirm no other production call site exists (only `hijosMusicaLocal` calls `itemsLocales`; verified via `Grep` in this session, single caller).
- [x] 2.12 Regression: confirm Phase 1 scenarios (empty subfolder, folder browse, playback resolution, art fallback) and the existing paging spy test (`navegacion_auto_test.dart:493-551`, adapted for the new async signature) still pass unchanged in behavior.
## Phase 3: Quality Sort, Name Buckets, Media-ID Wiring
- [x] 3.1 RED: `navegacion_auto_test.dart` — quality-sort comparator: tracks with known bitrate sort descending; a track with `bitrate: null` sorts after all known-bitrate tracks, never throws.
- [x] 3.2 GREEN: `lib/servicios/navegacion_auto.dart` — add quality comparator for `PistaLocal`/`MetadatosPista` bitrate desc, reusing `OrdenEmisoras.calidad`'s shape (`lib/estado/orden_emisoras.dart:14`) as the mirrored pattern (no code sharing forced — different types).
- [x] 3.3 RED: `navegacion_auto_test.dart``_maxPistasParaOrdenCalidad` boundary: folder with 149 tracks -> quality entry present; 150 -> present; 151 -> quality entry OMITTED from page-0 mode entries.
- [x] 3.4 GREEN: `lib/servicios/navegacion_auto.dart` — add `static const _maxPistasParaOrdenCalidad = 150`; quality-sort path batch-parses ALL folder tracks via `metadatosDe` (not page-scoped — full-folder, per ADR-3), sorts bitrate desc, caches, then applies existing `paginaDe`.
- [x] 3.5 RED: `test/servicios/navegacion_auto_test.dart` (new group `bucketsDe`) — partitioning: tracks named across multiple letters split into contiguous alphabetical buckets (e.g. A-F/G-M/...); a bucket with zero matches returns `[]` not an error; partitioning uses ONLY `NodoLocal.nombre` (metadata-free) — assert via a spy that `metadatosDe` is never called for bucket partitioning itself.
- [x] 3.6 GREEN: `lib/servicios/navegacion_auto.dart` — implement `bucketsDe(List<NodoLocal>)` pure-Dart, name-only partitioning (no metadata dependency), only offered when folder track count > 50 (design ADR-4).
- [x] 3.7 RED: `navegacion_auto_test.dart` — media-id encode/decode round-trip for `carpeta_local_ord:<modo>:<pagina>:<docId>` and `carpeta_local_bucket:<idxBucket>:<pagina>:<docId>`, including a docId containing `:`/`/` surviving verbatim (split-on-first-colon-after-fixed-fields chain, mirroring `paginaCarpetaLocalDesde`, `navegacion_auto.dart:319-325`).
- [x] 3.8 RED: `navegacion_auto_test.dart` — collision guards: assert `esCarpetaLocalOrdMediaId`/`esCarpetaLocalBucketMediaId` never both match the same id, and neither matches any of the other 4 existing prefixes (`emisora:`, `grupo:`, `eq_preset:`, `carpeta_local:`, `carpeta_local_pag:`, `pista:`) for representative sample ids of each.
- [x] 3.9 GREEN: `lib/servicios/navegacion_auto.dart` — add `_prefijoCarpetaLocalOrd = 'carpeta_local_ord:'`, `_prefijoCarpetaLocalBucket = 'carpeta_local_bucket:'` constants + `esCarpetaLocalOrdMediaId`/`esCarpetaLocalBucketMediaId` + decode helpers (mirroring `paginaCarpetaLocalDesde`'s split-on-first-colon-after-fixed-fields pattern for the extra `modo`/`idxBucket` field).
- [x] 3.10 RED: `navegacion_auto_test.dart` — page-0 mode entries: a folder with <=150 tracks and >50 tracks returns BOTH a quality-sort entry AND bucket entries prepended before the name-sorted list on page 0 only (mirrors `carpetasFavoritos` prepend precedent, `navegacion_auto.dart:416-430`); page >0 never re-prepends them.
- [x] 3.11 GREEN: `lib/servicios/navegacion_auto.dart` — wire mode/bucket entries into `itemsLocales`/`hijosMusicaLocal` on page 0 only, hardcoded Spanish labels ("Ordenar por calidad", bucket range labels e.g. "A-F") — matching the established car-tree precedent (`_tituloMasLocal = 'Más…'`, `_carpeta(idFavoritos, 'Favoritos')`, none of which go through `AppLocalizations`). **Do NOT add new keys to `lib/l10n/*.arb`** — these are car-tree-only labels, not phone UI; the only existing local-music `AppLocalizations` key (`localMusicFolderGenericName`) is phone-settings-only (`pantalla_ajustes.dart:366`), confirming the precedent split. If a genuinely new PHONE-facing string is introduced (none identified in spec/design as of this task pass), scope it into ALL 13 `lib/l10n/*.arb` files, not just en/es.
- [x] 3.12 GREEN: `lib/servicios/navegacion_auto.dart` — route the new `_ord`/`_bucket` media ids through `hijosMusicaLocal`'s dispatch (alongside existing `carpeta_local:`/`carpeta_local_pag:` branches, `navegacion_auto.dart:536-546`).
## Phase 4: Art Fallback & Final Regression
- [x] 4.1 RED: `navegacion_auto_test.dart` — art fallback matrix: cache-miss (native returns `artUri: null`) -> placeholder; parse-failure (metadata entry all-null for that docId) -> placeholder; never an empty/broken tile in any case.
- [x] 4.2 GREEN: confirm `_itemLocal`'s (Task 2.9) fallback branch already covers 4.1 — no new production code expected, this task is verification-only; if a gap is found, fix in `navegacion_auto.dart`.
- [x] 4.3 Full regression: run entire `test/servicios/navegacion_auto_test.dart` + `test/servicios/musica_local_auto_test.dart` + new `test/modelos/pista_local_test.dart` suites; confirm Phase 1 scenarios and paging invariant (Task 2.12) remain green.
- [x] 4.4 Deviated/manual-follow-up (not executable here, same convention as prior changes): `flutter analyze`, `flutter test --coverage`, `flutter gen-l10n` (only if 3.11's l10n gap is ever confirmed) — run manually before merge, not part of this task list's automated scope.
- [x] 4.5 Deviated/manual-follow-up: on-device hardware validation of `_maxPistasParaOrdenCalidad = 150` and the 256-file/32MB art budget (design "Open Questions") — no DHU/emulator coverage in this task list.
## Requirement Traceability
| Spec Requirement | Tasks |
|---|---|
| Local Music Browsable Tree (metadata title/art) | 1.1-1.7, 2.1-2.12, 4.1-4.3 |
| Local Music Sort Mode Navigation | 3.1-3.4, 3.9-3.12 |
| Local Music Alphabetical Name Buckets | 3.5-3.6, 3.9-3.12 |
| Local Track Embedded Album Art Display | 1.6-1.7, 2.6-2.9, 4.1-4.2 |
@@ -0,0 +1,75 @@
# Verify Report: android-auto-local-music-phase2
Date: 2026-07-19
Mode: Strict TDD verify, hybrid artifact store
Verdict: PASS WITH WARNINGS
## Completeness
- Tasks: 37/37 checked in openspec/changes/android-auto-local-music-phase2/tasks.md, 0 remaining.
- Diff size independently confirmed: git diff --stat (tracked) = 6 files, 1809 insertions(+), 82 deletions(-); plus 1 untracked file test/modelos/pista_local_test.dart (88 lines) = 1979 total changed lines, matching apply-progress claimed ~1979 exactly.
- Working tree: nothing committed. git status --short shows only the modified/untracked files listed above; git log HEAD is unrelated to this change (08cae2a chore: bump version...).
## Test Execution (independently re-run, not trusted from apply-progress)
Command: flutter test <target> --concurrency=1 --timeout=60s, run once combined and once per-file for precise counts.
| File | Apply-progress claim | Actual (re-run) | Match |
|---|---|---|---|
| test/servicios/navegacion_auto_test.dart | 114/114 | 114/114 | Yes |
| test/servicios/musica_local_auto_test.dart | 19/19 | 18/18 | No, off by 1 |
| test/modelos/pista_local_test.dart | 5/5 | 5/5 | Yes |
| Total | 138/138 | 137/137 | No, off by 1 |
All 137 actual tests pass, 0 failures. Confirmed by both a combined run and per-file runs, and by a static grep count of test( declarations in musica_local_auto_test.dart, which returns 18.
## Requirement-by-Requirement Findings
1. Load-bearing page-scoping invariant - CONFIRMED. itemsLocales (lib/servicios/navegacion_auto.dart:426-460) sorts, slices via paginaDe FIRST, then computes docIds from ONLY the sliced page non-directory nodes, THEN awaits metadatosDe(docIds). The dedicated spy test (navegacion_auto_test.dart:557-606, "THE load-bearing test") is stronger than a call-count check: it captures and asserts the exact list of docIds received - page 0 of 200 nodes receives exactly doc-0..doc-49, page 3 receives exactly doc-150..doc-199, never the full 200. A 500-track folder browsing page 1 would, by this same code path, only ever resolve metadata for the 50 sliced tracks.
2. Async conversion correctness - CONFIRMED. All 29 itemsLocales/itemsLocalesOrdenCalidad/itemsLocalesBucket call sites in the test file are awaited inside async test bodies. The single production caller (hijosMusicaLocal, navegacion_auto.dart:976) correctly awaits. Grep confirms itemsLocales( appears only twice in lib/ - its own definition and this one call site.
3. Nine-fixture self-reported deviation - LARGELY CONFIRMED, count is off by one. git diff shows 8 (not 9) pre-existing .single assertions converted to .singleWhere((i) => i.id.startsWith(pista prefix)). Read before/after: the fix genuinely narrows the assertion to the track item only (ignoring a newly-prepended "Ordenar por calidad" mode entry that now appears even for a 1-track folder, per the literal 0 < totalPistas <= 150 reading of ADR-3). The narrowed assertions still check title/artUri/id correctness on the correct item; nothing was silently dropped.
4. Quality-sort threshold - CONFIRMED. _maxPistasParaOrdenCalidad = 150 (navegacion_auto.dart:267) is directly boundary-tested at the pure-function level: ofreceOrdenCalidad(149) true, (150) true, (151) false (navegacion_auto_test.dart:1074-1081), plus an integration-level test with a real 151-node list confirming the entry is omitted (navegacion_auto_test.dart:1315-1345).
5. Metadata session cache - CONFIRMED flat (not folder-scoped), 256-entry LRU-by-access. CacheMetadatosSesion (musica_local_auto.dart:113-139) is a single flat LinkedHashMap; obtener removes and reinserts on hit (moves to MRU end), guardar evicts entries.first (LRU end) only when length exceeds 256. Two dedicated tests prove: (a) LRU-by-access - refreshing doc-0 recency before an eviction-triggering insert protects it while doc-1 is evicted instead; (b) LRU-by-insertion baseline without the refresh evicts doc-0. This structurally proves paging into page 2 cannot evict page 1 entries (only actual LRU pressure at 256+ entries evicts anything).
6. Media-id collision safety - CONFIRMED against all 6 pre-existing prefixes (emisora:, grupo:, eq_preset:, carpeta_local:, carpeta_local_pag:, pista:), read directly in dispatch code (navegacion_auto.dart:495-535) and asserted in a dedicated collision test (navegacion_auto_test.dart:1164-1218) that also checks the two new prefixes never both match the same id. DocId-with-colon-or-slash survival confirmed with a realistic SAF shape: ordenLocalDesde parsing "carpeta_local_ord:calidad:2:primary:Music/Local" correctly yields (calidad, primary:Music/Local, 2) at navegacion_auto_test.dart:1230-1235, same for bucketLocalDesde.
7. Art fallback - CONFIRMED. _itemLocal (navegacion_auto.dart:630-655) falls back to artUriLocal(documentId) (Phase 1 placeholder rotation) whenever meta artUri is null/blank. Dedicated fallback-matrix tests cover both cache-miss (artUri: null) and parse-failure (fully-null MetadatosPista) cases, asserting the resolved artUri is never empty and matches the placeholder rotation formula (navegacion_auto_test.dart:1448-1499).
8. Native Kotlin structural review - CONFIRMED sound, static-review-only (no build/DHU available, consistent with established project precedent for listAudioChildren/resolvePlayableUri/pickMusicFolder).
- MediaMetadataRetriever.setDataSource(this, documentUri) uses the correct (Context, Uri) overload against a DocumentsContract.buildDocumentUriUsingTree(parsedTree, documentId)-built SAF content URI (MainActivity.kt:498-499).
- retriever.release() runs unconditionally in a finally block, itself wrapped in try/catch so a failing release() cannot mask/replace the real result (MainActivity.kt:547-554). No resource leak.
- API-31 sample-rate guard is structurally correct: Build.VERSION.SDK_INT >= 31 gates the raw-key-38 (METADATA_KEY_SAMPLERATE) read; every other field is read unconditionally, matching ADR-5 (MainActivity.kt:506-513).
- FileProvider authority claim independently verified against live manifest/XML, not trusted from design.md: AndroidManifest.xml:97-105 declares android:authorities equal to applicationId.fileprovider with the pluriwave_file_paths xml resource; pluriwave_file_paths.xml:6-8 declares a cache-path entry named cache with path ".", which covers the entire cache dir including the new pluriwave_art/ subdir. Kotlin code uses packageName.fileprovider, which matches. Zero manifest changes were needed, confirming the design/apply-progress claim.
9. l10n discipline - CONFIRMED. git diff --stat and git status --short against lib/l10n/ both empty - zero changes to any of the 13 .arb files. Sort-mode/bucket labels ("Ordenar por calidad", "A-F", "G-M", "N-S", "T-Z") are hardcoded Spanish static const/literal strings in navegacion_auto.dart, with doc comments explicitly noting they never go through AppLocalizations - grep confirms zero AppLocalizations. calls in the file.
10. Hygiene - CONFIRMED clean. No TODO/FIXME/print/debugPrint/AI-attribution strings found in the diff across all 4 changed production files. Literal-encoding scan of the diff (mojibake patterns) returned zero hits; Spanish literals render correctly.
11. Working tree - CONFIRMED nothing committed (see Completeness section).
12-13. Test count and diff size - see tables above; diff size matches exactly, test count is off by one file (18 actual vs 19 claimed for musica_local_auto_test.dart).
## Issues
### CRITICAL
None.
### WARNING
1. Apply-progress test-count self-report inaccurate: claims 138/138 (114+19+5); actual is 137/137 (114+18+5). musica_local_auto_test.dart has 18 tests, not 19 (verified by both test-runner output and a static test( count). All 137 real tests pass - this is a reporting-accuracy issue, not a functional defect, but it is the third apply-progress numeric discrepancy flagged this session per the orchestrator own warning, and should be corrected before archive.
2. Apply-progress "9 fixtures" claim off by one: git diff shows 8 pre-existing .single assertions converted to .singleWhere(pista-prefixed), not 9. The mechanism/rationale of the fix is verified genuine and non-weakening (narrows to the track item, does not drop any check), but the count itself is inaccurate.
### SUGGESTION
- Native Kotlin (readAudioMetadataBatch/art cache/LRU trim) remains static-review-only per established project precedent - no build/DHU available in this environment. Design own "Open Questions" already flags the 150-track cap and 256-file/32MB art budget as needing on-device validation before this ships to real hardware; treat that as a pre-merge/pre-release gate, not a blocker for this SDD cycle.
- Minor CRLF line-ending normalization warnings appeared on git diff for 3 files (pista_local.dart, musica_local_auto.dart, test/servicios/musica_local_auto_test.dart) - cosmetic, not a defect.
## Verdict
PASS WITH WARNINGS. All spec requirements (Local Music Browsable Tree metadata/art, Sort Mode Navigation, Alphabetical Name Buckets, Embedded Album Art Display) are genuinely implemented and covered by passing tests that assert real behavioral invariants (exact docId lists, exact boundary values, exact collision non-matches), not just superficial results. The single riskiest element - the sync-to-async itemsLocales conversion - is correct: every call site awaits, the single production caller awaits, and the page-scoping invariant that motivated the whole design holds under direct code and test inspection. The only issues found are two instances of apply-progress under/over-counting its own claimed numbers by one; both are corrected here with independently re-verified figures. Recommend correcting apply-progress claimed counts before archive, but no code changes are required.
## Next Recommended
sdd-archive (after correcting the two count discrepancies in apply-progress, or accepting them as noted deviations).
+88
View File
@@ -0,0 +1,88 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/modelos/pista_local.dart';
void main() {
group('MetadatosPista', () {
test('todos los campos null: no lanza, todos los getters devuelven null', () {
const metadatos = MetadatosPista(
titulo: null,
artista: null,
artUri: null,
bitrate: null,
sampleRate: null,
);
expect(metadatos.titulo, isNull);
expect(metadatos.artista, isNull);
expect(metadatos.artUri, isNull);
expect(metadatos.bitrate, isNull);
expect(metadatos.sampleRate, isNull);
});
test('const constructor sin argumentos (todos por defecto null)', () {
const metadatos = MetadatosPista();
expect(metadatos.titulo, isNull);
expect(metadatos.artista, isNull);
expect(metadatos.artUri, isNull);
expect(metadatos.bitrate, isNull);
expect(metadatos.sampleRate, isNull);
});
test('todos los campos poblados se preservan tal cual', () {
const metadatos = MetadatosPista(
titulo: 'Cancion Genial',
artista: 'Artista X',
artUri: 'content://es.freetimelab.pluriwave.fileprovider/cache/pluriwave_art/abc',
bitrate: 320000,
sampleRate: 44100,
);
expect(metadatos.titulo, 'Cancion Genial');
expect(metadatos.artista, 'Artista X');
expect(
metadatos.artUri,
'content://es.freetimelab.pluriwave.fileprovider/cache/pluriwave_art/abc',
);
expect(metadatos.bitrate, 320000);
expect(metadatos.sampleRate, 44100);
});
});
group('PistaLocal', () {
test('campos extendidos (artista/embeddedArtUri/bitrate/sampleRate) son '
'opcionales y por defecto null', () {
const pista = PistaLocal(
documentId: 'doc1',
titulo: 'Titulo',
contentUri: 'content://provider/doc1',
);
expect(pista.artista, isNull);
expect(pista.embeddedArtUri, isNull);
expect(pista.bitrate, isNull);
expect(pista.sampleRate, isNull);
});
test('campos extendidos se preservan cuando se proveen', () {
const pista = PistaLocal(
documentId: 'doc1',
titulo: 'Titulo',
contentUri: 'content://provider/doc1',
artista: 'Artista X',
embeddedArtUri:
'content://es.freetimelab.pluriwave.fileprovider/cache/pluriwave_art/abc',
bitrate: 320000,
sampleRate: 44100,
);
expect(pista.artista, 'Artista X');
expect(
pista.embeddedArtUri,
'content://es.freetimelab.pluriwave.fileprovider/cache/pluriwave_art/abc',
);
expect(pista.bitrate, 320000);
expect(pista.sampleRate, 44100);
});
});
}
+161
View File
@@ -1,7 +1,168 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/modelos/pista_local.dart';
import 'package:pluriwave/servicios/musica_local_auto.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('CacheMetadatosSesion', () {
test(
'almacena hasta 256 entradas; la entrada 257 desaloja la '
'menos-recientemente-ACCEDIDA (no solo la menos recientemente '
'insertada)',
() {
final cache = CacheMetadatosSesion();
for (var i = 0; i < 256; i++) {
cache.guardar('doc-$i', MetadatosPista(titulo: 'T$i'));
}
expect(cache.obtener('doc-0'), isNotNull);
// Accede a doc-0 (la más vieja) para refrescar su recencia antes de
// insertar la entrada 257 — así doc-1 (no doc-0) debe ser la
// desalojada.
cache.obtener('doc-0');
cache.guardar('doc-256', const MetadatosPista(titulo: 'T256'));
expect(cache.obtener('doc-0'), isNotNull);
expect(cache.obtener('doc-1'), isNull);
expect(cache.obtener('doc-256'), isNotNull);
},
);
test(
'sin refrescar recencia: insertar la entrada 257 desaloja la '
'entrada 0 (la menos recientemente insertada Y accedida)',
() {
final cache = CacheMetadatosSesion();
for (var i = 0; i < 256; i++) {
cache.guardar('doc-$i', MetadatosPista(titulo: 'T$i'));
}
cache.guardar('doc-256', const MetadatosPista(titulo: 'T256'));
expect(cache.obtener('doc-0'), isNull);
expect(cache.obtener('doc-1'), isNotNull);
expect(cache.obtener('doc-256'), isNotNull);
},
);
test('obtener() en un miss devuelve null, sin lanzar', () {
final cache = CacheMetadatosSesion();
expect(cache.obtener('doc-inexistente'), isNull);
});
test('guardar() sobre una clave existente actualiza el valor', () {
final cache = CacheMetadatosSesion();
cache.guardar('doc-1', const MetadatosPista(titulo: 'Original'));
cache.guardar('doc-1', const MetadatosPista(titulo: 'Actualizado'));
expect(cache.obtener('doc-1')?.titulo, 'Actualizado');
});
});
group('FuenteMusicaLocalAutoImpl.metadatosDe', () {
const canal = MethodChannel('pluriwave/file_actions');
Future<SharedPreferences> prefsConCarpeta() async {
SharedPreferences.setMockInitialValues({
'musica_local_uri': 'content://tree/primary:Music',
});
return SharedPreferences.getInstance();
}
tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(canal, null);
});
test(
'documentIds vacío devuelve {} sin invocar el canal',
() async {
var llamadas = 0;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(canal, (call) async {
llamadas++;
return <Map<String, Object?>>[];
});
final fuente = FuenteMusicaLocalAutoImpl(prefs: await prefsConCarpeta());
final resultado = await fuente.metadatosDe(const []);
expect(resultado, isEmpty);
expect(llamadas, 0);
},
);
test(
'el canal lanzando una excepción degrada a {} en vez de propagar',
() async {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(canal, (call) async {
throw PlatformException(code: 'ERROR');
});
final fuente = FuenteMusicaLocalAutoImpl(prefs: await prefsConCarpeta());
final resultado = await fuente.metadatosDe(const ['doc-1']);
expect(resultado, isEmpty);
},
);
test(
'una fila nativa con campo null/faltante produce un MetadatosPista '
'con ese campo null, sin lanzar',
() async {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(canal, (call) async {
expect(call.method, 'readAudioMetadataBatch');
return [
{
'documentId': 'doc-1',
'titulo': null,
'artista': 'Artista',
'bitrate': null,
'sampleRate': null,
'artUri': null,
},
];
});
final fuente = FuenteMusicaLocalAutoImpl(prefs: await prefsConCarpeta());
final resultado = await fuente.metadatosDe(const ['doc-1']);
expect(resultado, hasLength(1));
expect(resultado['doc-1']?.titulo, isNull);
expect(resultado['doc-1']?.artista, 'Artista');
expect(resultado['doc-1']?.bitrate, isNull);
},
);
test(
'sin carpeta persistida devuelve {} sin invocar el canal',
() async {
SharedPreferences.setMockInitialValues({});
var llamadas = 0;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(canal, (call) async {
llamadas++;
return <Map<String, Object?>>[];
});
final fuente = FuenteMusicaLocalAutoImpl(
prefs: await SharedPreferences.getInstance(),
);
final resultado = await fuente.metadatosDe(const ['doc-1']);
expect(resultado, isEmpty);
expect(llamadas, 0);
},
);
});
group('esArchivoAudio', () {
test('acepta cualquier MIME audio/*, en cualquier capitalización', () {
expect(esArchivoAudio('audio/mpeg', 'cancion.mp3'), isTrue);
File diff suppressed because it is too large Load Diff