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.
7.0 KiB
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
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
pickMusicFolderusesstartActivityForResult(new to this Activity) — flagged for careful static review since it cannot be runtime-verified here.