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.
370 lines
21 KiB
Markdown
370 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 |
|