Files
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

21 KiB
Raw Permalink Blame History

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: getChildrenhijosMusicaLocalfuente.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 MediaItems for AT MOST min(tamano, remaining) entries per call — never one per total-folder-size. This is enforced structurally by ordering the operations:

List<MediaItem> itemsLocales(
  List<NodoLocal> nodos, {
  required String documentIdPadre,
  int pagina = 0,
  int tamano = _maxItemsCarpetaLocal,               // 50
  @visibleForTesting MediaItem Function(NodoLocal)? construirItem,
}) {
  final construir = construirItem ?? _itemLocal;     // expensive: art rotation + title
  final ordenados = [...nodos]..sort((a, b) => a.nombre.compareTo(b.nombre));
  final pagina0 = paginaDe(ordenados, pagina: pagina, tamano: tamano); // CHEAP slice
  final items = pagina0.map(construir).toList();      // build ONLY the slice
  if (hayPaginaSiguiente(ordenados.length, pagina: pagina, tamano: tamano)) {
    items.add(_itemMasLocal(documentIdPadre, pagina + 1));
  }
  return items;
}

with the generic, reusable, pure helpers:

List<T> paginaDe<T>(List<T> items, {required int pagina, required int tamano}) =>
    items.skip(pagina * tamano).take(tamano).toList();

bool hayPaginaSiguiente(int total, {required int pagina, required int tamano}) =>
    total > (pagina + 1) * tamano;

The expensive mapper (_itemLocal) is injectable via a @visibleForTesting parameter so the memory constraint is an asserted architectural invariant, not a comment. @visibleForTesting is already the codebase idiom (debeReaplicarEcualizador).

Testable constraint (must exist in the test suite). Given a folder of 200 NodoLocals, itemsLocales(nodos, documentIdPadre: 'x', pagina: 0, tamano: 50, construirItem: countingSpy) MUST invoke countingSpy exactly 50 times, and pagina: 3 MUST invoke it exactly min(50, 200 - 150) = 50 times, and the last partial page exactly remaining times. A naive nodos.map(_itemLocal).skip().take() regression would call the spy 200 times and FAIL this test — that is precisely the regression this seam catches. paginaDe is additionally unit-tested in isolation: it returns NodoLocals (a type that carries no art/title), so by construction it cannot have built a MediaItem.

Boundary cases (all covered by tests):

  • 50 items / page 0 → 50 items, NO "Más…" (50 > 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 / (…/MyFolderMyFolder);
    • else if it contains :, take everything after the last : (primary:MusicMusic);
    • 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:

    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):

    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 MediaItems constructed. The settings friendly-name change is phone UI and may need only a light or no spec delta.

6. Testing Strategy (Strict TDD)

  • paginaDe<T> — slicing across page boundaries, empty list, page beyond range, partial last page (generic, pure).
  • hayPaginaSiguiente — exact boundary (total == (pagina+1)*tamano → false).
  • itemsLocalescall-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