docs(openspec): archive android-auto-local-music-paging

Merges its delta requirements into the android-auto-media base spec.
This closes out Phase-1 polish for local music; Phase 2 (metadata,
sort/filter, real art) and Phase 3 (subfolder scoping, shuffle)
remain the only planned future work for this feature.
This commit is contained in:
2026-07-19 22:21:45 +02:00
parent 725169cd31
commit e030a0975d
8 changed files with 183 additions and 5 deletions
@@ -0,0 +1,369 @@
# 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<T>`) 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:<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 with `carpeta_local:` — at index 13 the paged token has `_`, the plain
prefix has `:`. Symmetric­ally, a plain `carpeta_local:<docId>` 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<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 `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<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:
```dart
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
`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://<authority>/tree/<encoded-documentId>`. 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<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)
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<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 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 |