Folders over the 50-item cap now show a "Mas..." item that reveals the next page on tap, instead of silently dropping the rest. Paging slices the cheap raw list before building any MediaItem, so items beyond the requested page are never resolved (art, title) -- proven by a call-count test. Also swaps the raw SAF content:// URI shown in settings for a parsed, human-readable folder name with a localized fallback across all 13 locales. servicio_audio.dart is untouched; this stays entirely within the local-music tree/dispatch layer.
371 lines
21 KiB
Markdown
371 lines
21 KiB
Markdown
# 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 `:`. Symmetrically, 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 |
|
||
```
|