# 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:`; tracks `pista:`. 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: ─▶ fuente.hijos(docId) ─▶ itemsLocales(nodos) [native lists 1 level] Car taps pista: ─▶ 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 hayCarpetaConfigurada(); Future> hijos(String documentId); // '' = tree root; never throws Future 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.