Files
pluriwave/openspec/changes/archive/2026-07-19-android-auto-local-music-paging/design.md
T
FreeTLab e030a0975d 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.
2026-07-19 22:21:45 +02:00

370 lines
21 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 |