# 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: 1. a generic slice function (`paginaDe`) that operates on the **cheap** `NodoLocal` DTO list BEFORE any `MediaItem` is built; 2. a "next page exists" predicate (`hayPaginaSiguiente`); 3. a synthetic browsable "Más…" trailing item whose media-id round-trips the `(documentId, nextPage)` pair back through `getChildren`. 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: -> (, 0) carpeta_local_pag:: -> (, n) ← NEW branch └─ fuente.hijos(documentId) -> List [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:: 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::` **Decision.** The "Más…" item's id is `carpeta_local_pag::`, where `` is the zero-based index of the page to LOAD when tapped, and `` 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 with `carpeta_local:` — at index 13 the paged token has `_`, the plain prefix has `:`. Symmetric­ally, a plain `carpeta_local:` never starts with `carpeta_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 parser `paginaCarpetaLocalDesde(id) -> (String docId, int pagina)` live next to the existing `esCarpetaLocalMediaId` / `idCarpetaLocalDesde` helpers, 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.** 1. **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. 2. **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. 3. **Freshness.** Re-query reflects on-disk changes between taps; a cache would serve stale entries. 4. **Cost is user-triggered and off the hot path.** SAF `listAudioChildren` is a single `ContentResolver.query` + cursor walk, fast for realistic folders, and runs only when the driver explicitly taps "Más…" — not on a timer or during playback. 5. **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` 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 `MediaItem`s for AT MOST `min(tamano, remaining)` entries per call — never one per total-folder-size. This is enforced structurally by ordering the operations: ```dart List itemsLocales( List 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: ```dart List paginaDe(List 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 `NodoLocal`s, `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 `NodoLocal`s (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 > 50` is 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.** 1. `Uri.tryParse(treeUri)`; on `null` → generic fallback (step 4). 2. SAF tree URIs are `content:///tree/`. Dart's `uri.pathSegments` returns already-percent-decoded segments, so the segment after `tree` is the decoded documentId (e.g. `primary:Music/MyFolder`, `1A2B-3C4D:Music`). 3. 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**. 4. **Final fallback** (documentId empty/opaque, e.g. a storage root `primary:` or a cloud provider's `msf:123`): a localized generic label `localMusicFolderGenericName` (see ADR-5). **Never** the raw `content://` 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:** 1. **Car "Más…" item → hardcoded Spanish constant**, NOT an arb key: ```dart 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 13 `app_*.arb` files 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…" `MediaItem` is browsable (`playable: false`), list content-style (`_contentStyleLista`), **no `artUri`** (consistent with `_carpeta`, which sets none — the label alone is the affordance): ```dart MediaItem _itemMasLocal(String documentIdPadre, int siguientePagina) => MediaItem( id: '$_prefijoCarpetaLocalPaginada$siguientePagina:$documentIdPadre', title: _tituloMasLocal, playable: false, extras: _contentStyleLista, ); ``` 2. **Phone settings generic folder-name fallback → NEW arb key `localMusicFolderGenericName`** across ALL 13 locales (`ar,bn,de,en,es,fr,hi, id,it,ja,pt,ru,zh`). This is genuine phone UI, which DOES use `AppLocalizations` (`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` 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`, `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) 1. **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. 2. **`servicio_audio.dart` NOT modified (ADR-7).** Proposal listed a new `getChildren` branch; design shows `hijosMusicaLocal` absorbs the paged id, so the file is untouched — a scope reduction. 3. **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 `MediaItem`s 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` — 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 construirItem` spy (exactly `min(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 returns `null`; `null` fuente → `[]`; thrown error → `[]`. - `nombreCarpetaDesdeUri` — internal-storage URI, SD-card URI, nested folder, storage-root (empty tail) → generic fallback, unparseable → generic fallback, never empty, never the raw `content://` 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 |