Files
pluriwave/openspec/changes/archive/2026-07-19-android-auto-local-music/design.md
T
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

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