Folders over the 50-item cap now show a "Mas..." item that reveals the next page on tap, instead of silently dropping the rest. Paging slices the cheap raw list before building any MediaItem, so items beyond the requested page are never resolved (art, title) -- proven by a call-count test. Also swaps the raw SAF content:// URI shown in settings for a parsed, human-readable folder name with a localized fallback across all 13 locales. servicio_audio.dart is untouched; this stays entirely within the local-music tree/dispatch layer.
21 KiB
Design: Android Auto Local Music — On-Demand Paging + Friendly Folder Name
Executor: sdd-design. Reads: proposal.md (this change), base spec
openspec/specs/android-auto-media/spec.md, live code
(navegacion_auto.dart, servicio_audio.dart, musica_local_auto.dart,
pantalla_ajustes.dart, MainActivity.kt).
1. Architecture Approach
Pure-Dart, additive on the existing browse seams — zero new state, zero
native surface. The Phase 1 local-music tree is a stateless, cold-start-safe,
never-throws pipeline: getChildren → hijosMusicaLocal → fuente.hijos()
(one SAF level) → ConstructorArbolAuto.itemsLocales (pure mapping). This change
extends that pipeline WITHOUT introducing the pipeline's first piece of mutable
state. Paging is expressed as three pure, testable transforms:
- a generic slice function (
paginaDe<T>) that operates on the cheapNodoLocalDTO list BEFORE anyMediaItemis built; - a "next page exists" predicate (
hayPaginaSiguiente); - a synthetic browsable "Más…" trailing item whose media-id round-trips the
(documentId, nextPage)pair back throughgetChildren.
The friendly folder name is a fourth pure transform (nombreCarpetaDesdeUri) on
the persisted SAF URI string, used only by the phone settings UI.
Nothing here needs a MethodChannel, a package, or a widget tree. Everything is
unit-testable in isolation — required by the active Strict TDD mode.
2. Component & Data-Flow Map
CAR browse "Música Local" folder tap
ServicioAudio.getChildren(parentMediaId) [servicio_audio.dart]
└─ hijosMusicaLocal(parentMediaId, fuente) [navegacion_auto.dart]
resolves parentMediaId into (documentId, pagina):
idMusicaLocal -> ('', 0)
carpeta_local:<docId> -> (<docId>, 0)
carpeta_local_pag:<n>:<docId> -> (<docId>, n) ← NEW branch
└─ fuente.hijos(documentId) -> List<NodoLocal> [musica_local_auto.dart]
(native listAudioChildren returns the WHOLE level; unchanged)
└─ constructor.itemsLocales(nodos,
documentIdPadre: documentId, pagina: pagina)
1. sort full NodoLocal list (cheap) ← O(n log n), no MediaItem
2. paginaDe(ordenados, pagina, 50) ← slice CHEAP list first
3. slice.map(_itemLocal) ← build ≤50 MediaItems ONLY
4. if hayPaginaSiguiente(...) append "Más…" ← carpeta_local_pag:<n+1>:<docId>
PHONE settings "current folder" row [pantalla_ajustes.dart]
_carpetaActual (raw content:// tree URI)
└─ nombreCarpetaDesdeUri(uri) -> friendly name [musica_local_auto.dart, NEW]
Every arrow is synchronous pure Dart except fuente.hijos() (the existing native
SAF query) — unchanged from Phase 1.
3. Load-Bearing Decisions (ADRs)
ADR-1 — Paged media-id scheme: carpeta_local_pag:<page>:<docId>
Decision. The "Más…" item's id is
carpeta_local_pag:<nextPage>:<rawDocumentId>, where <nextPage> is the
zero-based index of the page to LOAD when tapped, and <rawDocumentId> is the
parent folder's SAF documentId verbatim (may itself contain : and /).
Parsing. Strip the carpeta_local_pag: prefix by length, then split on the
first : only: everything left of it is the page integer (never contains a
colon), everything right of it — including any further colons/slashes — is the
raw documentId. Root paging is expressible: the root docId is '', so root
page 1 is carpeta_local_pag:1: (empty tail).
Collision proof vs the 5 existing prefixes (emisora:, grupo:,
eq_preset:, carpeta_local:, pista:) and bare folder ids
(favoritos/todas/mis_emisoras/ecualizador/musica_local):
- The only near-neighbor is
carpeta_local:.carpeta_local_pag:...does NOT start withcarpeta_local:— at index 13 the paged token has_, the plain prefix has:. Symmetrically, a plaincarpeta_local:<docId>never starts withcarpeta_local_pag:for the same reason. The two predicates are mutually exclusive; routing order between them is irrelevant to correctness (paged is checked first only for readability). - New predicate
esCarpetaLocalPaginadaMediaId(id) => id.startsWith('carpeta_local_pag:')and parserpaginaCarpetaLocalDesde(id) -> (String docId, int pagina)live next to the existingesCarpetaLocalMediaId/idCarpetaLocalDesdehelpers, mirroring their "strip by length, survive raw://" convention.
Encoding the page directly in the id (chosen) vs. an opaque page token.
Encoding the page number literally keeps the whole pipeline stateless — the id
IS the cursor. No server-side page registry, no session token to expire. This is
the established MediaBrowserService "load more via synthetic item" workaround
and is fully deterministic.
ADR-2 — Re-query the folder on every "Más…" tap; do NOT cache
Decision. Each "Más…" tap re-invokes fuente.hijos(documentId) (one native
SAF query returning the full level), re-sorts deterministically, and slices to
the requested page. No in-memory cache of raw entries between taps.
Why re-query wins over a session cache.
- Preserves the single most valuable property of this layer: statelessness. The browse pipeline is currently pure and cold-start-safe. A cache would be the FIRST mutable field in it, dragging in invalidation, staleness, clear-on- revoke, and thread-safety concerns — disproportionate to the benefit.
- Determinism already guarantees stable page boundaries. The sort
(
a.nombre.compareTo(b.nombre)) makes page N identical across re-queries as long as the folder is unchanged, so re-slicing is correct without a cache. - Freshness. Re-query reflects on-disk changes between taps; a cache would serve stale entries.
- Cost is user-triggered and off the hot path. SAF
listAudioChildrenis a singleContentResolver.query+ cursor walk, fast for realistic folders, and runs only when the driver explicitly taps "Más…" — not on a timer or during playback. - Matches the proposal's stated intent ("Nothing is cached between taps → minimal memory") and YAGNI.
Escape hatch (designed-for, not built). If real-world profiling ever shows
pathologically large folders make the re-query sluggish, a session-scoped cache
of the cheap List<NodoLocal> keyed by documentId is a localized, additive
optimization that slots BEHIND the same pure paginaDe seam without changing the
media-id scheme or the mapping contract. Not in scope now.
ADR-3 — Memory guarantee: slice the cheap list, THEN map (never map-then-slice)
Decision. itemsLocales MUST build MediaItems for AT MOST
min(tamano, remaining) entries per call — never one per total-folder-size. This
is enforced structurally by ordering the operations:
List<MediaItem> itemsLocales(
List<NodoLocal> nodos, {
required String documentIdPadre,
int pagina = 0,
int tamano = _maxItemsCarpetaLocal, // 50
@visibleForTesting MediaItem Function(NodoLocal)? construirItem,
}) {
final construir = construirItem ?? _itemLocal; // expensive: art rotation + title
final ordenados = [...nodos]..sort((a, b) => a.nombre.compareTo(b.nombre));
final pagina0 = paginaDe(ordenados, pagina: pagina, tamano: tamano); // CHEAP slice
final items = pagina0.map(construir).toList(); // build ONLY the slice
if (hayPaginaSiguiente(ordenados.length, pagina: pagina, tamano: tamano)) {
items.add(_itemMasLocal(documentIdPadre, pagina + 1));
}
return items;
}
with the generic, reusable, pure helpers:
List<T> paginaDe<T>(List<T> items, {required int pagina, required int tamano}) =>
items.skip(pagina * tamano).take(tamano).toList();
bool hayPaginaSiguiente(int total, {required int pagina, required int tamano}) =>
total > (pagina + 1) * tamano;
The expensive mapper (_itemLocal) is injectable via a @visibleForTesting
parameter so the memory constraint is an asserted architectural invariant,
not a comment. @visibleForTesting is already the codebase idiom
(debeReaplicarEcualizador).
Testable constraint (must exist in the test suite). Given a folder of 200
NodoLocals, itemsLocales(nodos, documentIdPadre: 'x', pagina: 0, tamano: 50, construirItem: countingSpy) MUST invoke countingSpy exactly 50 times, and
pagina: 3 MUST invoke it exactly min(50, 200 - 150) = 50 times, and the last
partial page exactly remaining times. A naive nodos.map(_itemLocal).skip().take()
regression would call the spy 200 times and FAIL this test — that is precisely
the regression this seam catches. paginaDe is additionally unit-tested in
isolation: it returns NodoLocals (a type that carries no art/title), so by
construction it cannot have built a MediaItem.
Boundary cases (all covered by tests):
- 50 items / page 0 → 50 items, NO "Más…" (
50 > 50is false; nothing dropped). - 51 items / page 0 → 50 items + "Más…"(page 1); page 1 → 1 item, no "Más…".
- 0 items →
[](Spec "browsing an empty subfolder returns an empty list"). - Stale page beyond range (folder shrank between taps) → empty slice, no "Más…",
[], no throw.
ADR-4 — Friendly folder name: pure-Dart URI parse with layered fallback (NO native round-trip)
Native reality (confirmed by static review of MainActivity.kt).
pickMusicFolder returns treeUri.toString() — the raw tree URI, nothing else.
listAudioChildren projects COLUMN_DISPLAY_NAME for each child, but never
the picked root folder's own name. hasPersistedPermission returns a bool.
No existing native response carries the root's display name — reusing one is
impossible. The only alternatives are (A) pure-Dart URI parsing, or (B) a new
native method returning DocumentFile.getName() of the tree root.
Decision: (A) pure-Dart nombreCarpetaDesdeUri(String treeUri) -> String.
Zero native surface, fully unit-testable, appropriate for a cosmetic settings
label. A native round-trip would add a channel method, untestable (in Dart)
MainActivity code, and an async/permission failure path — disproportionate.
Algorithm.
Uri.tryParse(treeUri); onnull→ generic fallback (step 4).- SAF tree URIs are
content://<authority>/tree/<encoded-documentId>. Dart'suri.pathSegmentsreturns already-percent-decoded segments, so the segment aftertreeis the decoded documentId (e.g.primary:Music/MyFolder,1A2B-3C4D:Music). - Extract the trailing readable part of that documentId:
- if it contains
/, take everything after the last/(…/MyFolder→MyFolder); - else if it contains
:, take everything after the last:(primary:Music→Music); - trim. If non-empty → friendly name.
- if it contains
- Final fallback (documentId empty/opaque, e.g. a storage root
primary:or a cloud provider'smsf:123): a localized generic labellocalMusicFolderGenericName(see ADR-5). Never the rawcontent://URI, never empty/blank.
Fallback text is a localized generic label, NOT the raw URI (a refinement of
the proposal, which defaulted to raw-URI fallback). The entire purpose of this
change is to STOP surfacing the raw URI; showing it in the rare unreadable-
provider case would defeat the intent. content://…/tree/msf%3A123 is not
"readable," so the raw URI is never an acceptable fallback. Trade-off: this adds
ONE phone-UI l10n key across 13 locales (small, and consistent with the "never
regress locales" lesson). The zero-l10n alternative — falling back to the decoded
documentId string — is noted but rejected as still-ugly.
Provider brittleness (accepted risk). Internal storage and SD card produce
readable trailing segments; Downloads/cloud providers may produce opaque ids —
these degrade cleanly to the generic label, never to a crash or a broken string.
If field reports later show the generic label appears too often for common
setups, the native DocumentFile.getName() round-trip (option B) is the
pre-identified fallback, isolated to nombreCarpetaDesdeUri's call site.
ADR-5 — "Más…" item and locale strategy (OVERTURNS a proposal assumption)
Finding. The car browse tree labels in navegacion_auto.dart are hardcoded
Spanish, not localized: 'Favoritos', 'Todas las emisoras', 'Ecualizador',
'Música Local', and the track fallback _tituloLocalFallback = 'Pista sin nombre' — explicitly documented as "hardcoded Spanish, matching every other
car-tree label in this file… none of which go through AppLocalizations." The
handler resolves l10n to Locale('es') by default for the tree.
Decision — two DISTINCT strings, two DISTINCT locale strategies:
-
Car "Más…" item → hardcoded Spanish constant, NOT an arb key:
const _tituloMasLocal = 'Más…';Adding an arb key for ONLY this item while
'Favoritos','Música Local', and'Pista sin nombre'stay hardcoded would be architecturally incoherent. The "Más…" item is a car-tree label and MUST follow the car-tree convention. This overturns the proposal's scope item "Update ALL 13app_*.arbfiles for the 'Más…' label" — that item was written assuming the car label is localized like phone UI, which it is not. No arb change for "Más…".The "Más…"
MediaItemis browsable (playable: false), list content-style (_contentStyleLista), noartUri(consistent with_carpeta, which sets none — the label alone is the affordance):MediaItem _itemMasLocal(String documentIdPadre, int siguientePagina) => MediaItem( id: '$_prefijoCarpetaLocalPaginada$siguientePagina:$documentIdPadre', title: _tituloMasLocal, playable: false, extras: _contentStyleLista, ); -
Phone settings generic folder-name fallback → NEW arb key
localMusicFolderGenericNameacross ALL 13 locales (ar,bn,de,en,es,fr,hi, id,it,ja,pt,ru,zh). This is genuine phone UI, which DOES useAppLocalizations(l10n.localMusicFolderNotConfigured, etc.). The Phase 1 "en/es-only omission" lesson applies here: scope all 13 locales from the start. Key name is non-colliding with the 8 Phase 1 keys (localMusicSectionTitle,localMusicSectionDescription,localMusicFolderTitle,localMusicFolderNotConfigured,localMusicChangePath,localMusicChoosePath,localMusicFolderUpdated,localMusicFolderSaveError). Suggested value: es"Carpeta seleccionada", en"Selected folder".
Net l10n scope: ONE new phone-UI key across 13 locales — NOT the car "Más…"
label. This is the inverse of what the proposal anticipated and MUST be
reflected in sdd-spec / sdd-tasks.
ADR-6 — Reusable generic paging; radio deliberately untouched
Decision. paginaDe<T> and hayPaginaSiguiente are generic, top-level,
pure functions (type parameter T, no NodoLocal coupling). Any future folder
type that needs paging can reuse the slice math directly. This satisfies the
task's "design for reasonable future reuse" without expanding scope:
- The reusable part is the slice arithmetic (
paginaDe/hayPaginaSiguiente) — already radio-ready, but no radio code is touched and no radio path calls it. - The domain-specific part is the media-id encoding (
carpeta_local_pag:round-trips a SAF documentId). Radio would need its OWN prefix + its own "Más…" builder if it were ever paged — deliberately NOT created here. Radio-station folders (hijos,_maxItemsPorCarpeta) keep cap-and-truncate, untouched (proposal "Out of Scope"; Spec "Favorite Group…"/station cap requirements unchanged).
ADR-7 — getChildren needs NO change; hijosMusicaLocal absorbs the paged id (scope reduction)
Decision. hijosMusicaLocal already owns ALL local-music media-id shapes and
returns null only for non-local ids so getChildren can fall through. By adding
the carpeta_local_pag: branch INSIDE hijosMusicaLocal, getChildren in
servicio_audio.dart requires no edit — it still calls
hijosMusicaLocal(...) and returns the non-null result. This reduces the
proposal's stated scope (which listed a new getChildren branch). Verified safe:
a carpeta_local_pag: id equals neither browsableRootId, idEcualizador, nor a
grupo: id, so it reaches hijosMusicaLocal before any other branch. The Más
item is non-playable, so playFromMediaId is never invoked on it; even if it
were, it matches none of the eq_preset:/pista:/emisora: prefixes and no-ops.
playFromMediaId therefore also needs no change.
4. Integration Points / Affected Files (revised)
| File | Change | Detail |
|---|---|---|
lib/servicios/navegacion_auto.dart |
Modified | Add paginaDe<T>, hayPaginaSiguiente, _prefijoCarpetaLocalPaginada='carpeta_local_pag:', esCarpetaLocalPaginadaMediaId, paginaCarpetaLocalDesde, _tituloMasLocal='Más…', _itemMasLocal; extend itemsLocales signature (documentIdPadre, pagina, tamano, @visibleForTesting construirItem) with slice-then-map; add paged branch to hijosMusicaLocal |
lib/servicios/musica_local_auto.dart |
Modified | Add pure nombreCarpetaDesdeUri(String) -> String |
lib/pantallas/pantalla_ajustes.dart |
Modified | _SeccionMusicaLocal subtitle (~line 361) renders nombreCarpetaDesdeUri(carpeta) instead of raw carpeta; generic fallback via l10n.localMusicFolderGenericName |
lib/l10n/app_{ar,bn,de,en,es,fr,hi,id,it,ja,pt,ru,zh}.arb |
Modified | ONE new key localMusicFolderGenericName (phone UI). NOT a "Más…" key. |
lib/servicios/servicio_audio.dart |
Unchanged | ADR-7: hijosMusicaLocal absorbs the paged id; getChildren/playFromMediaId untouched |
MainActivity.kt / pubspec.yaml |
Unchanged | No native / dependency change |
5. Overturned Proposal Assumptions (flag for sdd-spec / sdd-tasks)
- l10n direction inverted (ADR-5). Proposal: add the "Más…" label to 13 arb
files. Design: "Más…" is a HARDCODED-Spanish car-tree label (matching all
existing car-tree strings) — NO arb key. The ONLY new arb key is the phone-UI
friendly-name fallback
localMusicFolderGenericName(13 locales). This does not weaken the "never regress locales" guard — it retargets it to the correct string. servicio_audio.dartNOT modified (ADR-7). Proposal listed a newgetChildrenbranch; design showshijosMusicaLocalabsorbs the paged id, so the file is untouched — a scope reduction.- Fallback text refined (ADR-4). Proposal defaulted the friendly-name fallback to the raw URI; design forbids ever showing the raw URI and uses a localized generic label instead.
No native change is required — the proposal's core "pure-Dart, no native" premise
holds. (Confirmed: the friendly name does NOT need DocumentFile.getName();
URI parsing suffices, with native as a documented fallback only.)
Spec delta anticipated (sdd-spec owns it): the base spec's requirement
"Local Music Folder Item Cap" (currently: cap at 50, "pagination is out of scope
for this delta") must be REPLACED by a pagination requirement — every item
reachable via "Más…", no silent truncation, and only the requested page's
MediaItems constructed. The settings friendly-name change is phone UI and may
need only a light or no spec delta.
6. Testing Strategy (Strict TDD)
paginaDe<T>— slicing across page boundaries, empty list, page beyond range, partial last page (generic, pure).hayPaginaSiguiente— exact boundary (total == (pagina+1)*tamano→ false).itemsLocales— call-count invariant via@visibleForTesting construirItemspy (exactlymin(tamano, remaining)builds; catches map-then-slice regression); "Más…" appended iff a next page exists; "Más…" id shape and round-trip; deterministic sort → stable pages across calls; root paging (documentIdPadre: '').esCarpetaLocalPaginadaMediaId/paginaCarpetaLocalDesde— collision-free vs all 5 prefixes + bare ids; correct(docId, page)parse with docIds containing:and/; empty-tail root case.hijosMusicaLocal— paged id routes to the right(documentId, pagina); non-local id still returnsnull;nullfuente →[]; thrown error →[].nombreCarpetaDesdeUri— internal-storage URI, SD-card URI, nested folder, storage-root (empty tail) → generic fallback, unparseable → generic fallback, never empty, never the rawcontent://string.
7. Risks
| Risk | Likelihood | Mitigation |
|---|---|---|
| Re-enumerating the folder per "Más…" tap repeats one SAF query | Med | Accepted (ADR-2): user-triggered, off hot path, deterministic sort keeps pages stable; cache escape hatch behind the same paginaDe seam if profiling ever demands it |
| Pure-Dart friendly-name brittle for exotic SAF providers | Med | Layered fallback → localized generic label, never crash/empty/raw-URI; native DocumentFile.getName() pre-identified as isolated fallback |
| l10n scope confusion (car label vs phone key) regresses locales | Med | ADR-5 makes the split explicit: hardcoded "Más…" (car), 13-locale localMusicFolderGenericName (phone); tasks must not add a "Más…" arb key |
| Map-then-slice regression silently wastes memory | Low | @visibleForTesting mapper spy asserts exact build count (ADR-3) |
Deep folders still hold the full cheap NodoLocal list per level |
Low | DTOs are tiny (id + name + bool); only the paged MediaItem build is bounded |