Files
FreeTLab 977cbcd8cc docs(openspec): archive android-auto-local-music Phase 1
Merges its delta requirements into the android-auto-media base spec.
Phases 2 (metadata/sort/filter/art) and 3 (subfolder scoping/shuffle)
remain planned future work.
2026-07-19 20:37:54 +02:00

121 lines
7.0 KiB
Markdown

# Design: Android Auto Local Music — Phase 1
## Technical Approach
Extend the existing screaming-architecture seams, do NOT fork them. Local music becomes a
new browse domain that slots into the SAME lazy `getChildren`/`playFromMediaId` dispatch used
by stations, groups and EQ presets. All routing, id parsing, filename→title mapping, cap and
fallback-art logic lives in pure Dart in `navegacion_auto.dart` (fully unit-testable). Native
Kotlin stays thin and static-review-only: it just walks ONE `DocumentFile` level on demand and
returns a serializable node list, mirroring the already-lazy per-folder browse model.
## Architecture Decisions
### Decision: Hand-rolled SAF channel, not `shared_storage`
**Choice**: Extend the existing `pluriwave/file_actions` `MethodChannel` in `MainActivity.kt`
with lazy per-level SAF methods. **Rejected**: adding `shared_storage` (or similar).
**Rationale**: `shared_storage` is unmaintained (dependency-vetting risk); native is
static-review-only EITHER way here, so the package buys no testability. Hand-rolling gives full
control of the wire shape, lets us filter audio at the native layer (lean payload), and adds
ZERO new pub dependencies. The channel already speaks `DocumentsContract` — this is a natural
extension, not new surface.
### Decision: Lazy per-folder enumeration, never an eager tree dump
**Choice**: `listAudioChildren(treeUri, parentDocumentId)` returns ONE level (subfolders +
audio files). **Rejected**: eager recursive JSON of the whole tree. **Rationale**: libraries
reach thousands of files; a full tree round-trip is slow and memory-heavy. The existing
`getChildren` is already lazy per folder tap — one native call per browsed level mirrors it
exactly, bounds latency/memory to a single folder, and naturally respects the row cap.
### Decision: Pure SAF, no `READ_MEDIA_AUDIO`, no `permission_handler`
**Choice**: `ACTION_OPEN_DOCUMENT_TREE` + `takePersistableUriPermission` only. **Rejected**:
`READ_MEDIA_AUDIO`/`MediaStore` + `permission_handler`. **Rationale**: a persisted tree grant
reads everything under the picked root with NO dangerous runtime permission, is scoped-storage
compliant, needs no Play-Store data-access justification, and leaves the manifest permission set
UNCHANGED. Net: no new manifest permission, no runtime-request flow, no new Dart dep.
### Decision: Media-id scheme `musica_local` / `carpeta_local:` / `pista:`
**Choice**: root folder id `musica_local`; subfolders `carpeta_local:<documentId>`; tracks
`pista:<documentId>`. Prefix stripped by length so the raw `documentId` (which itself contains
`:`/`/`) survives verbatim. **Rationale**: collision-free against `emisora:`, `grupo:`,
`eq_preset:` and the bare folder ids. Playback content URI is resolved lazily at play time via
the source, so ids stay short.
### Decision: Local root hidden until a folder is configured; placed before Ecualizador
**Choice**: order = Favoritos, Todas, Mis emisoras, **Música Local**, Ecualizador; the local
folder is OMITTED from `raiz()` when no folder is persisted. **Rationale**: content-browsing
folders lead, the EQ tool trails (existing ADR-2); hiding an unconfigured root mirrors the
empty-group hidden-folder precedent (no dead ends).
### Decision: Dedicated 50-item cap, alphabetical truncation
**Choice**: separate `_maxItemsCarpetaLocal = 50`, sort by filename, truncate. **Rejected**:
higher/unbounded cap. **Rationale**: driver-distraction parity with stations; pagination is
explicitly deferred (Phase 2/3). Separate constant is the extension point (native call can later
take a page offset). No metadata in Phase 1, so ordering is alphabetical, deterministic, stable.
### Decision: Title = filename minus extension; art = reused `station_art_*` rotation
**Choice**: `titulo` = display name with the last `.ext` stripped (whole name if no dot;
non-empty fallback constant if blank/null). `artUri` = the existing 4-asset rotation seeded by
`documentId` via the existing `indiceArtePara`. **Rationale**: zero new assets, on-brand,
deterministic per-track art, reuses tested rotation infra. A distinct local-track placeholder is
deferred polish.
## Data Flow
Phone Settings ──pickMusicFolder──▶ SAF picker ──persist──▶ SharedPreferences('musica_local_uri')
Car browse root ─▶ raiz(incluirMusicaLocal: fuente.hayCarpetaConfigurada())
Car taps Música Local / carpeta_local:<id> ─▶ fuente.hijos(docId) ─▶ itemsLocales(nodos) [native lists 1 level]
Car taps pista:<id> ─▶ reproducirPistaLocal ─▶ fuente.uriContenido(docId) ─▶ playMediaItem(content:// item)
## File Changes
| File | Action | Description |
|------|--------|-------------|
| `lib/modelos/pista_local.dart` | Create | `PistaLocal` + `NodoLocal` DTO (documentId, name, isDirectory) |
| `lib/servicios/musica_local_auto.dart` | Create | `FuenteMusicaLocalAuto` abstraction + channel-backed impl (cold-start safe, never throws) |
| `lib/servicios/navegacion_auto.dart` | Modify | Add `carpeta_local:`/`pista:` predicates, `titulo`, `artUriLocal`, `itemsLocales`, `reproducirPistaLocal`; `raiz(incluirMusicaLocal:)` |
| `lib/servicios/servicio_audio.dart` | Modify | Register `_fuenteMusicaLocalGlobal`; new `getChildren` branches (root flag, `musica_local`, `carpeta_local:`) + `playFromMediaId` `pista:` branch |
| `lib/pantallas/pantalla_ajustes.dart` | Modify | New `_SeccionMusicaLocal` folder-pick section (mirrors `_SeccionGrabaciones`) |
| `android/.../MainActivity.kt` | Modify | Add `pickMusicFolder`, `listAudioChildren`, `resolvePlayableUri`, `hasPersistedPermission` to `file_actions` channel (static-review-only) |
Manifest and `pubspec.yaml`: NO changes required (pure-SAF, no new dep/permission).
## Interfaces / Contracts
```dart
class NodoLocal { final String documentId; final String nombre; final bool esDirectorio; }
abstract class FuenteMusicaLocalAuto {
Future<bool> hayCarpetaConfigurada();
Future<List<NodoLocal>> hijos(String documentId); // '' = tree root; never throws
Future<String?> uriContenidoDePista(String documentId); // content:// or null
}
```
Native `listAudioChildren` returns `[{documentId, nombre, esDirectorio}]`, filtering files to
`audio/*` MIME; Dart re-validates via pure `esArchivoAudio(mime, nombre)` (defense + testable).
## Testing Strategy
| Layer | What to Test | Approach |
|-------|-------------|----------|
| Unit | id predicates, `titulo` (ext strip / no-dot / blank), `artUriLocal`, `itemsLocales` cap+map, `raiz` visibility, `reproducirPistaLocal` (stale/unknown = no-op), cold-start empty | Pure Dart `flutter test`, fake `FuenteMusicaLocalAuto` |
| Static review | 4 new Kotlin channel methods, SAF persist/re-validate | No Android build here — code review only |
## Migration / Rollout
No migration. Additive behind a hidden root that only appears once a folder is picked. Rollback
= remove the local branches/files + the settings section; stations untouched.
## Open Questions
- None blocking. Native `pickMusicFolder` uses `startActivityForResult` (new to this Activity) —
flagged for careful static review since it cannot be runtime-verified here.