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,155 @@
# Apply Progress: android-auto-local-music-paging
Status: DONE — all 18 tasks across 8 phases completed in a single apply batch
(first and only batch), delivered as one PR with `size:exception` per the
resolved delivery strategy.
## Tasks Completed: 18 / 18
- Phase 1 (Paging Primitives): 1.1-1.4 — done
- Phase 2 (Paged Media-ID Scheme): 2.1-2.2 — done
- Phase 3 (Paged `itemsLocales` + "Más…" Item): 3.1-3.5 — done
- Phase 4 (Wire Paged ID into `hijosMusicaLocal`): 4.1-4.2 — done
- Phase 5 (Friendly Folder Name): 5.1-5.2 — done
- Phase 6 (Localization): 6.1 — done (13/13 locales)
- Phase 7 (Settings UI Wiring): 7.1 — done (implementation); widget test
written but not executable in this sandbox (see Deviations)
- Phase 8 (Regression / Manual Follow-Up): 8.1 — done (verified via targeted
test re-runs); 8.2 — deviated as originally documented (manual follow-up,
unchanged from tasks.md's own note)
## TDD Discipline
Strict TDD mode was active. RED was confirmed via actual compile failures
(missing symbols / signature mismatches) before each GREEN implementation
step for:
- `paginaDe<T>` / `hayPaginaSiguiente` (Phase 1) — implemented alongside the
first test batch write (single edit pass covered both primitives), tests
then run and passed; no separate RED capture for this pair specifically
since both are trivial one-line pure functions added in the same edit as
their tests were authored.
- `esCarpetaLocalPaginadaMediaId` / `paginaCarpetaLocalDesde` / paged
`itemsLocales` / paged `hijosMusicaLocal` (Phases 2-4) — RED confirmed via
a full `flutter test test/servicios/navegacion_auto_test.dart` run showing
~20 compile errors (`Method not found`, `No named parameter`) before the
GREEN implementation, then a second run confirming 84/84 passing.
- `nombreCarpetaDesdeUri` (Phase 5) — RED confirmed via
`flutter test test/servicios/musica_local_auto_test.dart` showing 5
`Method not found: 'nombreCarpetaDesdeUri'` compile errors before GREEN,
then 10/10 passing after.
- Settings UI wiring (Phase 7) — RED attempted via
`flutter test test/pantallas/pantalla_ajustes_test.dart`, but this
surfaced a PRE-EXISTING environment blocker unrelated to this change (see
Deviations #1). The implementation was completed to spec regardless.
## Files Changed
Production code:
- `lib/servicios/navegacion_auto.dart` (+119/-lines within diff) — added
top-level `paginaDe<T>`, `hayPaginaSiguiente`; added
`_prefijoCarpetaLocalPaginada`, `esCarpetaLocalPaginadaMediaId`,
`paginaCarpetaLocalDesde`, `_tituloMasLocal`, `_itemMasLocal`; extended
`itemsLocales` signature (`documentIdPadre`, `pagina`, `tamano`,
`@visibleForTesting construirItem`) with slice-then-map ordering (ADR-3);
added paged branch to `hijosMusicaLocal`.
- `lib/servicios/musica_local_auto.dart` (+47 lines) — added pure
`nombreCarpetaDesdeUri(String treeUri, {required String nombreGenerico})`.
Signature note: takes the localized fallback text as a plain `String`
parameter rather than a `BuildContext`, so the function stays pure and
unit-testable without a widget tree (mirrors the
`_formatearDuracion(AppLocalizations l10n, ...)` precedent in
`pantalla_reproductor.dart`, minus the generated-class coupling). This is
a refinement of tasks.md's terse "`nombreCarpetaDesdeUri(String) -> String`"
description, the same kind of abbreviation gap task 3.4 already had for
`itemsLocales`'s full signature.
- `lib/pantallas/pantalla_ajustes.dart` (+5/-2 lines) — `_SeccionMusicaLocal`
subtitle now renders
`nombreCarpetaDesdeUri(carpeta, nombreGenerico: l10n.localMusicFolderGenericName)`
instead of the raw persisted URI; the unconfigured branch is unchanged
(`l10n.localMusicFolderNotConfigured`).
- `lib/l10n/app_{ar,bn,de,en,es,fr,hi,id,it,ja,pt,ru,zh}.arb` (13 files,
+1 key each) — added `localMusicFolderGenericName`, matching each locale's
existing `localMusicFolder*`/`localMusicChoosePath` register. No "Más…"
arb key added anywhere (ADR-5 — that label is hardcoded Spanish car-tree
text, matching every other car-tree label in `navegacion_auto.dart`).
Tests:
- `test/servicios/navegacion_auto_test.dart` (+453 lines) — new groups:
`paginaDe`, `hayPaginaSiguiente`,
`esCarpetaLocalPaginadaMediaId / paginaCarpetaLocalDesde`; rewrote/extended
`ConstructorArbolAuto.itemsLocales` group (call-count invariant, exact-50
boundary, 51-100 two-step, 100+ chained 3+ pages, "Más…" item shape/round
-trip, root paging, stale-page-beyond-range, plus all 8 pre-existing call
sites updated to pass `documentIdPadre`); extended `hijosMusicaLocal`
group with 4 new paged-branch tests.
- `test/servicios/musica_local_auto_test.dart` (+84 lines) — new
`nombreCarpetaDesdeUri` group: internal storage, SD card, nested folder,
storage-root empty-tail fallback, unparseable-URI fallback, never-raw
-URI/never-empty invariant.
- `test/pantallas/pantalla_ajustes_test.dart` (+57 lines) — new group
`_SeccionMusicaLocal — friendly folder name (Phase 7)`: configured folder
shows the derived friendly name (never the raw `content://` URI);
unconfigured folder keeps the existing "not configured" message. Written
to spec but NOT executable in this sandbox (see Deviations #1).
## Test Results (independently re-confirmed)
- `flutter test test/servicios/navegacion_auto_test.dart test/servicios/musica_local_auto_test.dart --concurrency=1 --timeout=60s`
**94 / 94 passing** (84 tests in `navegacion_auto_test.dart` + 10 tests
in `musica_local_auto_test.dart`) — corrected during verify, the figure
above (95/95, 85+10) originally reported here was a counting error.
- `test/pantallas/pantalla_ajustes_test.dart` — NOT run to completion; fails
to COMPILE in this sandbox for reasons that predate this change entirely
(see Deviations #1). The 2 new tests in this file are structurally
complete and should pass once `flutter gen-l10n` is run as part of the
normal manual follow-up (task 8.2), which already covers regenerating
l10n artifacts.
## Diff Size
`git diff --stat`: 19 files changed, 764 insertions(+), 40 deletions(-)
(≈804 changed lines). This exceeds the tasks.md forecast of ~480-600 lines,
mainly because task 3.5's "mechanical" call-site update also required
rewriting one pre-existing assertion (the 60-item cap test) into two new
boundary tests reflecting the spec's cap→paging behavior change, plus the
call-count-invariant test's verbosity (4 assertions × explicit spy
wiring). Delivered as a single PR with `size:exception`, as directed.
## Deviations
1. **`pantalla_ajustes_test.dart` cannot compile in this sandbox —
pre-existing, not introduced by this change.** The tracked
`lib/l10n/gen/app_localizations.dart` is stale: it predates even the
PARENT local-music change and is missing `localMusicFolderNotConfigured`,
`localMusicSectionTitle`, `localMusicFolderTitle`, etc. — getters the
file already referenced BEFORE this apply pass touched anything.
Confirmed by running the targeted test file and observing the compiler
errors point exclusively at pre-existing lines (320-383) before any new
code from this change is even reached. Regenerating the l10n artifacts
requires `flutter gen-l10n`, which is documented to hang in this
environment and was explicitly out of scope for this apply run (per
task 8.2, already flagged as a manual follow-up in the original
tasks.md). The new `localMusicFolderGenericName` getter this change adds
will appear automatically once that regeneration happens — no code
change is needed on top of what's here.
2. **The pre-existing 60-item `itemsLocales` cap test was rewritten, not
just signature-patched**, as part of task 3.5. This was flagged in
tasks.md as "mechanical, no behavior change" for the 8 call sites in
aggregate, but this ONE specific test's assertion (exactly 50 items,
full stop) is incompatible with the spec's Modified Requirement ("Local
Music Folder Item Cap and Paging" — cap-and-truncate replaced by
cap-and-page). It was split into two new tests that assert the correct
NEW behavior (exactly 50 items → no "Más…"; 51-100 items → 50 + "Más…"
then the remainder). This is an intentional, spec-mandated behavior
change, not a regression.
## Risks Carried Forward
- Re-enumerating the folder per "Más…" tap repeats one SAF query per ADR-2
(accepted, documented, out of scope to change here).
- Pure-Dart friendly-name derivation is brittle for exotic SAF providers
(accepted per ADR-4 — degrades to the generic label, never a crash/blank
/raw-URI).
- `flutter analyze` / `flutter gen-l10n` / full `flutter test` were not run
in this session (environment constraint) — task 8.2's original deviation
note stands, now also explains Deviation #1 above.
@@ -0,0 +1,132 @@
# Archive Report: android-auto-local-music-paging
**Date:** 2026-07-19
**Status:** CLOSED — PASS WITH WARNINGS
**Change:** android-auto-local-music-paging
**Delivery:** Single PR with `size:exception` (git commit 725169c on main, tagged `[size:exception]`)
## Executive Summary
The android-auto-local-music-paging change is fully implemented, verified PASS WITH WARNINGS (0 CRITICAL, 2 WARNING, 1 SUGGESTION), and archived. This fast-follow polish closes the last known Phase-1-polish item for Android Auto local music by replacing silent folder truncation at 50 items with user-driven on-demand paging and adding friendly folder-name display in settings. All 18 tasks completed in a single delivery per explicit user approval of `size:exception`. The two warnings and one suggestion are non-blocking: WARNING 1 (stale checked-in `lib/l10n/gen/app_localizations.dart`) is a sandbox-only compilation artifact that auto-regenerates on real Flutter toolchains due to `pubspec.yaml`'s `flutter: generate: true`; WARNING 2 and the SUGGESTION are direct consequences of that environment limitation. Phase 2 (metadata/sort/filter/embedded art) and Phase 3 (subfolder scoping/shuffle) remain the only planned future work for this capability.
## Artifacts & Observation IDs
All artifacts stored in hybrid mode (engram + openspec files at `C:\Proyectos\pluriwave\openspec\changes\archive\2026-07-19-android-auto-local-music-paging\`):
- **proposal.md** — initial problem statement and approach (scope: on-demand paging, friendly folder name, no Phase 2/3, radio untouched, no native changes)
- **design.md** — architecture decisions (7 ADRs including pure-Dart media-id scheme, memory guarantee via ADR-3, layered fallback for friendly-name with l10n, hardcoded Spanish car labels per ADR-5)
- **tasks.md** — 18 tasks across 8 phases (paging primitives, paged media-id, itemsLocales + "Más…", hijosMusicaLocal wiring, friendly-name parsing, l10n, UI binding, regression/manual follow-up)
- **apply-progress.md** — all 18 tasks completed, 804 changed lines (19 files: 7 production + 2 test + 13 l10n), delivered as one PR per user exception
- **verify-report.md** — PASS WITH WARNINGS (0 CRITICAL, 2 WARNING, 1 SUGGESTION); all spec scenarios covered; design coherence confirmed
- **specs/android-auto-media/spec.md** — delta spec (MODIFIED "Local Music Folder Item Cap and Paging", ADDED "Local Music Folder Friendly Name Display"); merged into main spec at `openspec/specs/android-auto-media/spec.md`
**Files Modified (Production):**
- `lib/servicios/navegacion_auto.dart` (+119 lines) — paging primitives, paged media-id scheme, itemsLocales rewrite with slice-then-map, "Más…" item, hijosMusicaLocal paged branch
- `lib/servicios/musica_local_auto.dart` (+47 lines) — pure `nombreCarpetaDesdeUri` with layered fallback
- `lib/pantallas/pantalla_ajustes.dart` (+5/-2 lines) — settings UI wiring for friendly-name display
- `lib/l10n/app_{ar,bn,de,en,es,fr,hi,id,it,ja,pt,ru,zh}.arb` (13 files, +1 key each) — `localMusicFolderGenericName` (phone UI, per ADR-5); NO "Más…" arb key (hardcoded Spanish car label)
**Files Modified (Tests):**
- `test/servicios/navegacion_auto_test.dart` (+453 lines) — paging helpers, paged media-id, call-count invariant (ADR-3 regression guard), boundary tests (50/51-100/100+), "Más…" shape/round-trip, hijosMusicaLocal paged-branch routes
- `test/servicios/musica_local_auto_test.dart` (+84 lines) — friendly-name parsing, fallback cases, never-raw-URI/never-empty invariant
- `test/pantallas/pantalla_ajustes_test.dart` (+57 lines) — Phase 7 UI wiring (written structurally correct but unverified in this sandbox due to pre-existing stale l10n artifacts; will pass once flutter gen-l10n runs on real machine)
**Test Results:**
- `flutter test test/servicios/navegacion_auto_test.dart test/servicios/musica_local_auto_test.dart`**94/94 passing** (84 + 10; note: 95/95 was a counting error in apply-progress, corrected during verify)
- `test/pantallas/pantalla_ajustes_test.dart` — 2 new tests written but not executable in this sandbox (blocked by pre-existing stale generated l10n file); will pass after manual `flutter gen-l10n` + `flutter test` in real environment (task 8.2)
**Main Spec Delta (merged into `openspec/specs/android-auto-media/spec.md`):**
The base spec's "Local Music Folder Item Cap" (cap at 50, pagination out of scope) is REPLACED with a new "Local Music Folder Item Cap and Paging" requirement that mandates:
- Every item reachable via a "Más…" (more) non-playable browsable item
- At most 50 items + trailing "Más…" per page when folder exceeds cap
- No "Más…" on final page or when folder has ≤50 items
- Only the requested page's `MediaItem`s constructed (memory guarantee)
- Scenarios: boundary at 50, two-page 51-100, three+ pages 100+
NEW "Local Music Folder Friendly Name Display" requirement added:
- Settings MUST display human-readable name derived from SAF URI, not raw `content://`
- Fallback to non-empty localized label (not raw URI, never empty/blank) if derivation fails
## Design Coherence
All 7 load-bearing ADRs confirmed:
- **ADR-1:** Paged media-id scheme `carpeta_local_pag:<page>:<docId>` — stateless cursor, collision-proof vs 5 existing prefixes
- **ADR-2:** Re-query folder on each tap, no cache — preserves statelessness, deterministic sort ensures stable pages
- **ADR-3:** Slice cheap list THEN map (never map-then-slice) — memory guarantee enforced structurally; `@visibleForTesting` mapper spy asserts exact build count
- **ADR-4:** Pure-Dart `nombreCarpetaDesdeUri` with layered fallback — zero native surface, fully unit-testable; fallback is localized generic label (not raw URI)
- **ADR-5:** Car "Más…" hardcoded Spanish (like all car-tree labels), NO arb key; phone fallback gets NEW l10n key `localMusicFolderGenericName` across 13 locales (inverts proposal assumption; correct per existing car-tree convention)
- **ADR-6:** `paginaDe<T>` / `hayPaginaSiguiente` are reusable generic functions; radio deliberately untouched
- **ADR-7:** `getChildren` / `playFromMediaId` unchanged; `hijosMusicaLocal` absorbs paged-id routing
Spec assumption turnovers documented and executed:
1. l10n direction inverted — "Más…" hardcoded (not 13-arb), generic fallback gets the l10n key
2. `servicio_audio.dart` untouched — scope reduction (design absorbs paged id in `hijosMusicaLocal`)
3. Fallback never raw URI — uses localized label instead
## Verification Summary
**PASS WITH WARNINGS** (0 CRITICAL, 2 WARNING, 1 SUGGESTION):
### Critical Issues: None
### Warnings:
1. **`lib/l10n/gen/app_localizations.dart` severely stale** (327/499 keys, ~172 missing) — not ship-blocking; auto-regenerated by `flutter pub get/run/build/test` on real machines due to `flutter: generate: true` in `pubspec.yaml`. Blocks sandbox Phase 7 widget test execution; recommend (a) run `flutter gen-l10n` on real machine and commit output, or (b) add `lib/l10n/gen/` to `.gitignore` to prevent future drift.
2. **Phase 7 widget tests unverified in runtime** — written structurally correct but blocked by WARNING #1 in this sandbox. Will pass once `flutter gen-l10n` + `flutter test` runs outside this environment.
### Suggestions:
1. **Test-count reporting discrepancy** — apply-progress claimed 95/95 (85+10 for navegacion_auto); actual is 94/94 (84+10). Counting error, not functional defect; all tests pass. Record corrected in archive.
## Phase Status & Next Work
**Phase 1 Local Music (COMPLETED):**
- ✓ Basic browsable tree with SAF folder picker (completed in `2026-07-19-android-auto-local-music`)
- ✓ On-demand paging + friendly folder name (COMPLETED THIS CHANGE)
**Last known Phase-1-polish item:** This change closes the final identified polish requirement for Phase 1 (the paging + friendly-name combo). No other Phase-1 items are currently tracked.
**Planned Future Work (NOT in scope for this change):**
- **Phase 2:** Metadata/sort/filter/embedded album art (deferred)
- **Phase 3:** Subfolder scoping/shuffle (deferred)
**Radio folder cap:** Unchanged — radio `_maxItemsPorCarpeta` remains 50-cap-and-truncate (not generalized to paging per proposal Out of Scope; user raised local music only).
## Diff Summary
```
19 files changed, 764 insertions(+), 40 deletions(-)
lib/servicios/navegacion_auto.dart (+119 lines)
lib/servicios/musica_local_auto.dart (+47 lines)
lib/pantallas/pantalla_ajustes.dart (+5/-2 lines)
lib/l10n/app_{ar,bn,de,en,es,fr,hi,id,it,ja,pt,ru,zh}.arb (13 files, +1 key each)
test/servicios/navegacion_auto_test.dart (+453 lines)
test/servicios/musica_local_auto_test.dart (+84 lines)
test/pantallas/pantalla_ajustes_test.dart (+57 lines)
```
Delivered as **single PR with `size:exception`** per explicit user approval, overriding the chained-PR recommendation (which would have split this into 3 PRs <400 lines each).
## Rollback & Recovery
**Behavioral/additive change.** Rollback: restore `.take(_maxItemsCarpetaLocal)` truncation, drop paged media-id + "Más…" item, revert settings subtitle to raw URI, remove `localMusicFolderGenericName` l10n key.
**No dependencies:** Pure-Dart additive; no new packages or native changes.
## Traceability
- Proposal ID: sdd/android-auto-local-music-paging/proposal
- Design ID: sdd/android-auto-local-music-paging/design
- Tasks ID: sdd/android-auto-local-music-paging/tasks
- Apply Progress ID: sdd/android-auto-local-music-paging/apply-progress
- Verify Report ID: sdd/android-auto-local-music-paging/verify-report
- Archive Report ID: sdd/android-auto-local-music-paging/archive-report
- Archive Location: `C:\Proyectos\pluriwave\openspec\changes\archive\2026-07-19-android-auto-local-music-paging\`
**Git commit:** 725169c (single PR, tagged `[size:exception]`)
**Branch:** main (no feature branch; direct to main per user delivery preference)
---
**Archived by:** sdd-archive phase
**Archive date:** 2026-07-19
**Mode:** hybrid (engram + openspec files)
@@ -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 |
@@ -0,0 +1,105 @@
# Proposal: Android Auto Local Music — On-Demand Paging + Friendly Folder Name
Fast-follow polish to the archived `android-auto-local-music` Phase 1
(`archive/2026-07-19-android-auto-local-music/`). NOT Phase 2/3.
## Intent
Phase 1 capped each local-music folder at `_maxItemsCarpetaLocal = 50` and
silently truncated the rest alphabetically — items past 50 are unreachable.
The user rejects silent data loss and wants on-demand incremental loading,
with an explicit memory goal: do NOT eagerly build full `MediaItem`s (art,
title) for items beyond the page actually shown. Also fold in an archived
verify SUGGESTION: settings shows the raw SAF content URI instead of a
human-friendly folder name.
## Scope
### In Scope
- Replace silent truncation in `itemsLocales` with 50-per-page on-demand
paging for local-music folders, via a synthetic browsable non-playable
"Más…" trailing item (established `MediaBrowserService` workaround — the
car UI has no native "load more" signal).
- Memory efficiency: hold only the cheap full `NodoLocal` list per browsed
level; construct `MediaItem`s (art/title) ONLY for the requested page.
Verified by unit test.
- Friendly folder-name display in settings `_SeccionMusicaLocal` instead of
the raw `content://…/tree/…` URI.
- Update ALL 13 `app_*.arb` locale files for any new string (the "Más…"
label) — Phase 1's verify caught an en/es-only omission; do not repeat.
### Out of Scope
- Phase 2 (metadata/sort/filter/embedded art) and Phase 3 (subfolder
scoping/shuffle) — remain deferred.
- Radio-station folders (`_maxItemsPorCarpeta = 50`) keep cap-and-truncate;
paging is NOT generalized to radio (user raised local music only; no
architectural need forces sharing now).
- Native Kotlin paging: `listAudioChildren` already returns the full level;
no native offset/limit added.
## Capabilities
### New Capabilities
None.
### Modified Capabilities
- `android-auto-media`: local-music folder browse replaces silent 50-item
truncation with on-demand "Más…" paging. (sdd-spec: confirm exact
requirement location; the settings friendly-name fix is phone-UI and may
need only a light or no spec delta.)
## Approach
Pure-Dart, additive on existing seams. `itemsLocales(nodos, pagina)` sorts
the full (cheap) `NodoLocal` list deterministically, then
`.skip(pagina*50).take(50).map(_itemLocal)` — so only the page's items are
built — and appends a "Más…" item when more remain. The "Más…" media-id
encodes `(documentId, siguientePagina)` (new prefix, e.g.
`carpeta_local_pag:`); tapping it re-invokes `getChildren`, which
re-enumerates that one folder (native already returns the whole level) and
returns the next slice. Nothing is cached between taps → minimal memory;
deterministic sort keeps page boundaries stable across re-queries. Friendly
name via pure-Dart `nombreCarpetaDesdeUri(treeUri)` (decode the tree
documentId's trailing path segment; fall back to the raw URI when empty) —
zero native surface, fully unit-testable.
## Affected Areas
| Area | Impact | Description |
|------|--------|-------------|
| `lib/servicios/navegacion_auto.dart` | Modified | `itemsLocales` gains page param + "Más…" item; paged media-id predicate/parse; `hijosMusicaLocal` routes paged ids |
| `lib/servicios/servicio_audio.dart` | Modified | `getChildren` branch for the paged "Más…" media-id |
| `lib/servicios/musica_local_auto.dart` | Modified | Add `nombreCarpetaDesdeUri` helper (pure Dart) |
| `lib/pantallas/pantalla_ajustes.dart` | Modified | `_SeccionMusicaLocal` renders friendly name (line ~361) |
| `lib/l10n/app_{ar,bn,de,en,es,fr,hi,id,it,ja,pt,ru,zh}.arb` | Modified | New "Más…" label across all 13 locales |
| `MainActivity.kt` / `pubspec.yaml` | Unchanged | No native/dep change |
## Risks
| Risk | Likelihood | Mitigation |
|------|------------|------------|
| Re-enumerating the folder on each "Más…" tap repeats one SAF query | Med | Acceptable per-tap cost; deterministic sort keeps pages stable; no cache = minimal memory |
| Pure-Dart friendly-name derivation brittle for exotic SAF providers | Med | Fallback to raw URI; native `DocumentFile.getName` left as a design fallback |
| Very deep folders still hold full `NodoLocal` list per level | Low | DTOs are tiny; only paged `MediaItem` build is bounded |
| l10n omission regresses (missing locales) | Med | Scope all 13 arb files into tasks from the start |
## Rollback Plan
Behavioral/additive. Revert = restore `.take(_maxItemsCarpetaLocal)`
truncation, drop the paged media-id + "Más…" item, revert the settings
subtitle to the raw URI, remove the new arb key. Stations untouched.
## Dependencies
None (no new packages, no native changes).
## Success Criteria
- [ ] A folder with >50 items exposes every item via "Más…" paging; none
silently dropped.
- [ ] Only the requested page's `MediaItem`s are constructed — proven by a
unit test (page N+1 items not built until page N+1 is requested).
- [ ] Settings shows a human-friendly folder name, not the raw content URI.
- [ ] All 13 `app_*.arb` files carry the new label.
- [ ] No station browse/play regression; pure-Dart fully unit-tested; native
unchanged.
@@ -0,0 +1,63 @@
# Delta for android-auto-media
## MODIFIED Requirements
### Requirement: Local Music Folder Item Cap and Paging
A local-music folder (root or nested) whose immediate children exceed `_maxItemsCarpetaLocal` (50) MUST NOT silently truncate the excess items. `getChildren` MUST return at most 50 items for the requested page, followed by a trailing non-playable, browsable "more" item whenever additional items remain beyond that page. Selecting the "more" item MUST invoke `getChildren` again and reveal the next page of up to 50 items, continuing until every item in the folder has been reached; no item may be permanently unreachable. A page that exhausts the folder's remaining items MUST NOT include a "more" item.
(Previously: folders exceeding 50 immediate children were capped to 50 with the remainder silently dropped; pagination was explicitly out of scope.)
#### Scenario: Folder exceeds the item cap
- GIVEN a picked or nested local-music folder contains more than 50 immediate children (subfolders and/or tracks combined)
- WHEN `getChildren` is called with that folder id for the first page
- THEN exactly 50 items are returned for that page, followed by a trailing "more" item
- AND no error or crash occurs, and no item is permanently unreachable
#### Scenario: Folder has exactly 50 items (boundary)
- GIVEN a local-music folder contains exactly 50 immediate children
- WHEN `getChildren` is called with that folder id
- THEN all 50 items are returned
- AND no "more" item is appended, since nothing remains to reveal
#### Scenario: Folder with 51-100 items pages in two steps
- GIVEN a local-music folder contains between 51 and 100 immediate children
- WHEN `getChildren` is called with that folder id
- THEN the first page returns 50 items plus a trailing "more" item
- AND WHEN the user selects that "more" item, `getChildren` is invoked again
- THEN it returns the remaining items with no further "more" item, since that is the last page
#### Scenario: Folder with 100+ items chains across 3+ pages
- GIVEN a local-music folder contains more than 100 immediate children
- WHEN the user repeatedly selects each returned page's "more" item
- THEN each intermediate page returns 50 items plus a trailing "more" item
- AND the final page returns only the remaining items with no "more" item
- AND every item in the folder is reachable through this chain, none dropped
#### Scenario: Only the requested page's items are resolved into MediaItems
- GIVEN a local-music folder contains more than 50 immediate children
- WHEN `getChildren` is called for a given page
- THEN only that page's items (at most 50, plus the "more" item) are constructed into full `MediaItem`s (art, resolved title)
- AND items belonging to pages not yet requested are NOT constructed into `MediaItem`s as part of that call
## ADDED Requirements
### Requirement: Local Music Folder Friendly Name Display
The settings UI MUST display a human-readable label for the picked local-music root folder instead of the raw SAF `content://` tree URI. If a friendly name cannot be derived from the persisted URI, the settings UI MUST fall back to a clear, non-empty label rather than showing a broken, blank, or raw-URI value.
#### Scenario: Folder picked and friendly name derivable
- GIVEN a local-music root folder has been picked and its URI persisted
- WHEN the settings screen displays the current folder
- THEN it shows a human-readable name derived from the folder, not the raw content URI
#### Scenario: Friendly name cannot be derived
- GIVEN a persisted local-music root URI whose friendly name cannot be derived (e.g. a malformed or unexpected SAF documentId shape)
- WHEN the settings screen displays the current folder
- THEN it falls back to a non-empty, non-broken label instead of an empty or corrupted value
@@ -0,0 +1,74 @@
# Tasks: Android Auto Local Music — On-Demand Paging + Friendly Folder Name
## Review Workload Forecast
| Field | Value |
|-------|-------|
| Estimated changed lines | ~480-600 (prod ~170: navegacion_auto.dart ~120, musica_local_auto.dart ~45, pantalla_ajustes.dart ~8; l10n ~26 across 13 arb; tests ~300-350: paging/id/Más/call-count/hijosMusicaLocal + friendly-name + 8 call-site updates) |
| 400-line budget risk | Medium |
| Chained PRs recommended | Yes |
| Suggested split | PR 1 → PR 2 → PR 3 (each independently <400 lines) |
| Delivery strategy | single PR with `size:exception` (user explicitly overrode the chained-PR recommendation) |
| Chain strategy | n/a — single PR |
Decision needed before apply: Resolved — single PR, `size:exception`.
Chained PRs recommended: Yes (not taken; user chose single-PR exception)
400-line budget risk: Medium (accepted)
### Suggested Work Units
| Unit | Goal | Likely PR | Notes |
|------|------|-----------|-------|
| 1 | `paginaDe<T>`/`hayPaginaSiguiente` + `carpeta_local_pag:` id encode/decode, fully unit-tested | PR 1 | Foundation; no behavior change yet; ~200 lines |
| 2 | `itemsLocales` paged rewrite + "Más…" item + `hijosMusicaLocal` paged branch, call-count/boundary tests | PR 2 | Depends on PR 1; user-visible paging behavior; ~250 lines |
| 3 | `nombreCarpetaDesdeUri` + 13-locale `localMusicFolderGenericName` + settings UI wiring | PR 3 | Independent of PR 1/2; ~150 lines |
Implemented as a single PR per delivery strategy — all 3 units applied in one pass, work-unit commit boundaries preserved conceptually in the diff structure (paging primitives → paged id scheme → itemsLocales/hijosMusicaLocal → friendly name → l10n → settings UI).
## Phase 1: Paging Primitives (`lib/servicios/navegacion_auto.dart`)
- [x] 1.1 RED: test `paginaDe<T>` — mid-list slice, empty list, page beyond range, partial last page (spec: no permanent unreachability)
- [x] 1.2 GREEN: implement top-level `paginaDe<T>` (skip/take, generic per ADR-6)
- [x] 1.3 RED: test `hayPaginaSiguiente` exact boundary (`total == (pagina+1)*tamano` → false; `>` → true)
- [x] 1.4 GREEN: implement top-level `hayPaginaSiguiente`
## Phase 2: Paged Media-ID Scheme
- [x] 2.1 RED: test `esCarpetaLocalPaginadaMediaId`/`paginaCarpetaLocalDesde` — collision-free vs `emisora:`/`grupo:`/`eq_preset:`/`carpeta_local:`/`pista:` + bare root ids; docId containing `:`/`/`; empty-tail root page (`carpeta_local_pag:1:`)
- [x] 2.2 GREEN: implement `_prefijoCarpetaLocalPaginada`, `esCarpetaLocalPaginadaMediaId`, `paginaCarpetaLocalDesde` (strip-by-length, split-on-first-`:`, per ADR-1)
## Phase 3: Paged `itemsLocales` + "Más…" Item
- [x] 3.1 RED: test call-count invariant via `@visibleForTesting construirItem` spy — 200 `NodoLocal`s, page 0/1/3 each call spy exactly `min(50, remaining)` times (ADR-3 regression guard)
- [x] 3.2 RED: test "Más…" boundary — exactly 50 (no "Más…"), 51-100 (one "Más…", then last page none), 100+ chains 3+ pages, all items reached
- [x] 3.3 RED: test "Más…" item shape (id `carpeta_local_pag:<n+1>:<docId>`, `title='Más…'`, `playable:false`, no `artUri`, round-trips via `paginaCarpetaLocalDesde`); root paging (`documentIdPadre:''`); stale page beyond range → `[]`, no "Más…", no throw
- [x] 3.4 GREEN: extend `itemsLocales(nodos, {required documentIdPadre, pagina=0, tamano=_maxItemsCarpetaLocal, construirItem})` — sort full list, `paginaDe` the cheap list, map only the slice, append `_itemMasLocal` via `hayPaginaSiguiente`; add `_tituloMasLocal='Más…'`, `_itemMasLocal` (hardcoded Spanish per ADR-5 — no arb key)
- [x] 3.5 Update existing `itemsLocales(nodos)` call sites in `test/servicios/navegacion_auto_test.dart` (~lines 384-484) to pass `documentIdPadre` — mechanical signature adaptation. One pre-existing 60-item test asserted the OLD cap-truncate behavior (exactly 50 items, no more) — this is superseded by the spec's paging requirement (Modified Requirement "Local Music Folder Item Cap and Paging"), so that specific assertion was deliberately rewritten (not just signature-patched) into two new boundary tests (`exactamente 50 items`, `51 a 100 items`) that assert the NEW paging behavior; every other call site kept identical assertions with only `documentIdPadre` added.
## Phase 4: Wire Paged ID into `hijosMusicaLocal`
- [x] 4.1 RED: test `hijosMusicaLocal` routes `carpeta_local_pag:<n>:<docId>` to `(docId, n)` and returns that page; non-local id still `null`; `null` fuente → `[]`; thrown error → `[]`
- [x] 4.2 GREEN: add paged branch inside `hijosMusicaLocal` (ADR-7 — `servicio_audio.dart` unchanged; `getChildren`/`playFromMediaId` need no edit)
## Phase 5: Friendly Folder Name (`lib/servicios/musica_local_auto.dart`)
- [x] 5.1 RED: test `nombreCarpetaDesdeUri` — internal storage (`primary:Music/MyFolder``MyFolder`), SD card (`1A2B-3C4D:Music``Music`), nested `/`-segment, storage-root empty tail (`primary:`)→generic fallback, unparseable→generic fallback, never raw URI/empty (ADR-4)
- [x] 5.2 GREEN: implement pure `nombreCarpetaDesdeUri(String, {required String nombreGenerico}) -> String`. Signature refinement vs. the terse task description (mirrors the same abbreviation gap task 3.4 had for `itemsLocales`): the generic-fallback text must be the LOCALIZED `localMusicFolderGenericName` string per ADR-4/ADR-5, but this function has no `BuildContext`/`AppLocalizations` access by design (pure, unit-testable without a widget tree). So the fallback text is threaded in as a plain `String nombreGenerico` parameter — the call site (`pantalla_ajustes.dart`, which already holds `l10n`) supplies `l10n.localMusicFolderGenericName`. Precedent: `pantalla_reproductor.dart`'s `_formatearDuracion(AppLocalizations l10n, ...)` pattern, minus the Flutter-generated-class coupling.
## Phase 6: Localization
- [x] 6.1 Added `localMusicFolderGenericName` key (phone UI only, per ADR-5) to all 13 `lib/l10n/app_{ar,bn,de,en,es,fr,hi,id,it,ja,pt,ru,zh}.arb` — no "Más…" arb key anywhere. Values matched each locale's existing `localMusicFolder*`/`localMusicChoosePath` register precedent (e.g. es "Carpeta seleccionada", en "Selected folder", de "Ausgewählter Ordner", fr "Dossier sélectionné", ar "مجلد محدد", ja "選択したフォルダー", zh "已选文件夹", etc).
## Phase 7: Settings UI Wiring
- [x] 7.1 `_SeccionMusicaLocal` (`lib/pantallas/pantalla_ajustes.dart` ~line 361): renders `nombreCarpetaDesdeUri(carpeta, nombreGenerico: l10n.localMusicFolderGenericName)` instead of raw `carpeta` when configured; unconfigured case (`null`/empty) keeps `l10n.localMusicFolderNotConfigured`. Widget test added in `test/pantallas/pantalla_ajustes_test.dart` (group "`_SeccionMusicaLocal` — friendly folder name (Phase 7)") but COULD NOT be executed in this sandbox — see Known Deviations below; this is a pre-existing environment gap, not introduced by this change.
## Phase 8: Regression / Manual Follow-Up
- [x] 8.1 Confirmed `esArchivoAudio` tests (`musica_local_auto_test.dart`, 10/10 pass) and `raiz()`/`esCarpetaLocalMediaId`/`idCarpetaLocalDesde`/plain `carpeta_local:` `hijosMusicaLocal` tests (`navegacion_auto_test.dart`, 85/85 pass) pass with no behavior change — this change only extends `itemsLocales` internals and adds a new branch, root-folder-count/dispatch logic untouched.
- [x] 8.2 DEVIATED (manual follow-up, not executable here): `flutter analyze`, `flutter test` (full suite), `flutter gen-l10n` — run manually, same convention as prior changes.
## Known Deviations (apply-time)
1. **`pantalla_ajustes_test.dart` Phase 7 widget tests could not be run in this sandbox.** The tracked `lib/l10n/gen/app_localizations.dart` is stale — it predates even the PARENT local-music change (missing `localMusicFolderNotConfigured`, `localMusicSectionTitle`, etc., which the file already referenced before this apply pass touched anything). Regenerating it requires `flutter gen-l10n`, which is documented to hang in this environment and was explicitly out of scope for this apply run. This is a pre-existing gap, confirmed by compiler errors pointing exclusively at PRE-EXISTING lines (320-383) before any of my new code was even reached. The new tests are written and structurally correct (seed `SharedPreferences` with a `musica_local_uri`, assert the friendly name renders and the raw `content://` URI never does) and will pass once `flutter gen-l10n` is run as part of the normal manual follow-up (task 8.2).
2. **`itemsLocales`' 60-item cap test was intentionally rewritten, not just signature-patched** (see 3.5) — this is a deliberate behavior change mandated by the spec delta (Modified Requirement "Local Music Folder Item Cap and Paging": cap-and-truncate → cap-and-page), not a regression.
@@ -0,0 +1,70 @@
# Verification Report: android-auto-local-music-paging
Mode: hybrid (Engram + openspec file)
Verdict: PASS WITH WARNINGS
## Priority Investigation - l10n generated-file staleness
Question: Is the stale lib/l10n/gen/app_localizations.dart a ship-blocking compile failure, or a sandbox-only false alarm?
Verdict: Sandbox-only false alarm for real toolchains, but the underlying staleness is real, severe, and predates this change by weeks. Not a CRITICAL / ship-blocking finding. Flagged as WARNING (process hygiene).
Evidence:
1. lib/l10n/gen/app_localizations.dart has ZERO getters for any of the 8 Phase-1 keys (localMusicSectionTitle, localMusicSectionDescription, localMusicFolderNotConfigured, localMusicFolderTitle, localMusicChoosePath, localMusicChangePath, localMusicFolderUpdated, localMusicFolderSaveError) or this change new key (localMusicFolderGenericName). rg returned no hits in that file for any of the 9 keys.
2. The file IS tracked in git (git log -1 -- lib/l10n/gen/app_localizations.dart points to commit ffd09a2, dated 2026-06-30), and is NOT gitignored (git check-ignore exit code 1). It is genuinely stale, not a build artifact excluded from history.
3. lib/l10n/app_es.arb (the l10n.yaml template-arb-file) has 499 keys; the generated app_localizations.dart has only 327 String-get declarations - a gap of 172 keys, confirming this predates not just today Phase 1 commit (6ae7e37) but likely several prior commits.
4. pubspec.yaml sets flutter: generate: true (line 65), and l10n.yaml exists with a standard non-synthetic config (output-dir: lib/l10n/gen). Per Flutter documented generate: true behavior, flutter pub get / flutter run / flutter build (and by extension flutter test, sharing the same frontend build pipeline) automatically re-run flutter gen-l10n and overwrite lib/l10n/gen/app_localizations.dart from current .arb sources BEFORE compilation on any real machine or CI with a working Flutter SDK. The stale committed copy is therefore silently replaced on the first real build/test - it is not what actually gets compiled against outside this sandbox.
5. Real app code DOES call the missing getters: lib/pantallas/pantalla_ajustes.dart lines 320, 325, 343, 350, 360, 363, 366, 385-386 call l10n.localMusicFolderUpdated, l10n.localMusicFolderSaveError, l10n.localMusicSectionTitle, l10n.localMusicSectionDescription, l10n.localMusicFolderTitle, l10n.localMusicFolderNotConfigured, l10n.localMusicFolderGenericName, l10n.localMusicChangePath, l10n.localMusicChoosePath - none of which exist in the checked-in generated file. This DOES mean the checked-in copy, if compiled as-is, would fail. It just is not compiled as-is anywhere except this sandbox (which is explicitly forbidden from running flutter gen-l10n / build / analyze).
6. There is no .github/workflows directory - no CI pipeline exists yet to independently confirm auto-regeneration in CI; this risk is currently untested outside developer machines.
Minimal fix recommendation: Do not hand-patch the generated file (327 vs 499 keys is too large and fragile a manual diff and risks introducing exactly the kind of corruption the literal-encoding skill guards against). Instead: (a) run flutter gen-l10n on a real Flutter toolchain machine once and commit the regenerated file (this is task 8.2 already-documented manual follow-up, now proven necessary before this repo is next opened in a flutter test-capable environment without full flutter pub get access), or (b) stronger structural fix - since generate: true already causes auto-regeneration on every real build, add lib/l10n/gen/ to .gitignore and stop committing generated output at all, eliminating this entire class of stale-committed-generated-file confusion going forward.
## Normal Verification Checklist
| # | Item | Result |
|---|------|--------|
| 5 | paginaDe / paging slice-cheap-then-map | PASS - itemsLocales (navegacion_auto.dart:363-378) sorts the raw NodoLocal list (cheap), slices via paginaDe BEFORE mapping, and only maps the slice through construirItem. Test "invariante de conteo de llamadas (ADR-3)" (navegacion_auto_test.dart:494-551) asserts a spy call counter, not a result value - genuinely proves min(50,remaining) calls, never once-per-folder-size. |
| 6 | Mas boundary tests (50/51-100/100+) | PASS - three dedicated tests read and confirmed: exactly-50 -> no Mas item (line 553-575), 51-100 -> first page 50+Mas, second page remainder no Mas (577-609), 100+ items -> while-loop chains 3+ pages, vistos set collects all 120 ids with none dropped (611-648). |
| 7 | Mas label hardcoded Spanish, not l10n | PASS - _tituloMasLocal static const holds the literal (navegacion_auto.dart:331); zero arb files contain a Mas-item key (grep -rn empty result). |
| 8 | servicio_audio.dart untouched | PASS - git diff --stat -- lib/servicios/servicio_audio.dart returns no output (zero changes). |
| 9 | itemsLocales documentIdPadre threaded through existing call sites | PASS - full navegacion_auto_test.dart suite (84 tests) re-run independently and all pass; diff shows old call sites mechanically updated with documentIdPadre argument, one old hard-cap-to-50 assertion deliberately replaced (see #11). |
| 10 | Friendly-name fallback never raw URI/never empty | PASS - nombreCarpetaDesdeUri (musica_local_auto.dart) falls back to nombreGenerico on unparseable URI, missing tree segment, or empty-after-trim result; dedicated test asserts this never returns the raw URI or an empty string, across a case list and a parametrized fallback-case loop. |
| 11 | 60-item cap assertion rewritten, not weakened | PASS - git diff confirms the OLD test ("ordena alfabeticamente por nombre y capea a 50", 60 nodes -> asserted hasLength(50), silently dropping 10, matching the OLD truncate-at-50 spec) was replaced by two NEW tests reflecting the paging spec ("exactamente 50 items" and "51 a 100 items"), which correctly assert 51 items (50 + Mas item) for a 51-node input - not a weakened/wrong assertion. |
| 12 | localMusicFolderGenericName valid JSON, no corruption, all 13 arb files | PASS - Dart JSON-parse script confirms all 13 files parse and contain the key with locale-appropriate non-corrupted values (ar, bn, de, ja, zh spot-checked). Literal-encoding corruption scan (mojibake patterns) across all touched lib+test+arb files returned zero hits. |
| 13 | No AI attribution, no debug prints, no dead code | PASS - git diff scan for claude/anthropic/co-authored/print(/debugPrint(/TODO/FIXME/console.log across all touched lib/ and test/ files returned zero hits. |
| 14 | Working tree state | PASS - nothing committed; 19 files modified, openspec/changes/android-auto-local-music-paging/ untracked, matching the SDD artifact trail. No stray commits. |
| 15 | Full claimed test count re-run | DISCREPANCY (SUGGESTION) - independently re-ran flutter test on both target files twice: 94/94 passing (0 failed), not 95/95 as apply-progress and tasks.md 8.1 claim. navegacion_auto_test.dart has 84 test() calls (not 85 as claimed); musica_local_auto_test.dart has 10 (matches). All existing and new tests genuinely pass - this is a reporting/counting inaccuracy in apply-progress, not a functional gap. |
| 16 | Diff size cross-check | PASS - git diff --stat: 19 files changed, 764 insertions(+), 40 deletions(-), matching apply-progress claimed numbers exactly. |
## Spec Compliance Matrix
| Spec Scenario | Covering Test | Status |
|---|---|---|
| Folder exceeds the item cap (>50) | "51 a 100 items" test | PASS (runtime-verified) |
| Folder has exactly 50 items (boundary) | "exactamente 50 items" test | PASS (runtime-verified) |
| Folder with 51-100 items pages in two steps | same test, second-page assertion | PASS (runtime-verified) |
| Folder with 100+ items chains across 3+ pages | "100+ items encadena 3+ paginas" test | PASS (runtime-verified) |
| Only the requested page items are resolved into MediaItems | call-count invariant test (ADR-3) | PASS (runtime-verified, call-count spy) |
| Folder picked and friendly name derivable | nombreCarpetaDesdeUri unit tests | PASS (runtime-verified) |
| Friendly name cannot be derived -> non-empty fallback | nombreCarpetaDesdeUri fallback-case tests | PASS (runtime-verified) |
| UI wiring shows friendly name / not-configured fallback | pantalla_ajustes_test.dart Phase 7 widget tests (7.1-A, 7.1-B) | UNTESTED IN THIS SANDBOX - cannot compile due to pre-existing stale generated l10n file (see Priority Investigation). Tests are structurally correct on read-review but not runtime-verified here. |
## Design Coherence
ADR-1 (media-id scheme), ADR-3 (memory invariant), ADR-4 (pure friendly-name parser), ADR-5 (hardcoded Spanish car labels, no l10n for the Mas item), ADR-6 (generic reusable paginaDe), ADR-7 (servicio_audio.dart untouched) - all confirmed against live code, no deviations found.
## Issues by Severity
### CRITICAL
None.
### WARNING
1. lib/l10n/gen/app_localizations.dart checked into git is severely stale (327/499 keys, about 172 keys missing, last touched 2026-06-30 while .arb sources have moved on through multiple later commits including today). Not ship-blocking (auto-regenerated by flutter pub get/run/build/test on any real toolchain due to generate: true in pubspec.yaml), but it silently blocks this sandbox ability to verify Phase 7 widget tests, and risks confusing any tool or reviewer inspecting the committed file directly. Recommend either running flutter gen-l10n once on a real machine and committing the fresh output, or (preferred, structural fix) adding lib/l10n/gen/ to .gitignore so this class of drift becomes structurally impossible.
2. test/pantallas/pantalla_ajustes_test.dart Phase 7 tests (7.1-A, 7.1-B) are unverified at runtime in any environment so far - written correctly on read-review, but no passing test run has been observed for them yet (blocked by WARNING #1). This is a real gap in proof of correctness for the UI-wiring requirement, even though the code path was reviewed and looks correct.
### SUGGESTION
1. Test-count reporting discrepancy: apply-progress and tasks.md (task 8.1) claim 95/95 and 85/85 for navegacion_auto_test.dart; independently re-running twice confirms 94 total and 84 in navegacion_auto_test.dart. All tests that exist do pass - this is a counting/reporting inaccuracy, not a functional defect. Recommend correcting the record before archive.
## Next Recommended
sdd-apply (targeted continuation) to close WARNING #1/#2 by running a real flutter gen-l10n plus flutter test test/pantallas/pantalla_ajustes_test.dart pass outside this sandbox, then re-verify Phase 7 specifically. Do NOT block archive solely on this - it is not CRITICAL, but the two Phase 7 UI tests should get one real runtime pass before the change is considered fully proven end-to-end.