Files
pluriwave/openspec/changes/android-auto-local-music/apply-progress.md
T
FreeTLab 6ae7e378c4 feat(auto): browse and play local music folders in Android Auto [size:exception]
Phase 1: pick a device folder via SAF (persisted grant, no new
permission), browse its nested subfolders/tracks as a 5th Android
Auto root folder (hidden until configured), and play tracks through
the existing pipeline (EQ, art rotation, cold-start-safe source).
No metadata/sort/filter/shuffle yet -- filename is the title, generic
rotating art is the placeholder; deferred to a follow-up phase.

Adds a new pluriwave/file_actions native method (listAudioChildren)
and an onActivityResult override in MainActivity for the SAF folder
picker -- both static-review-only, no Android build available here.
2026-07-19 20:30:50 +02:00

13 KiB
Raw Blame History

Apply Progress: Android Auto Local Music — Phase 1

Status: done — all 12 task groups complete in a single pass. Delivery: single PR with size:exception (per orchestrator/user decision — matches repo precedent 35bb180 feat(auto): browsable Android Auto media tree with play-by-id [size:exception]). No slicing across batches. Mode: Strict TDD for all pure-Dart, behavior-changing tasks; native Kotlin and the on-device SAF picker round trip are static-review-only (no Android build/DHU available in this environment — same established precedent as prior Android Auto changes).

Completed Tasks

  • 1.11.2 PistaLocal/NodoLocal models (pure DTOs — no tests written, per task 1.2's explicit "getter-only classes, skip" rule)
  • 2.12.2 esArchivoAudio (RED → GREEN, musica_local_auto.dart)
  • 3.13.2 Media-id scheme: idMusicaLocal, _prefijoCarpetaLocal, _prefijoPista, esCarpetaLocalMediaId, esPistaMediaId, idCarpetaLocalDesde (RED → GREEN, collision + :-in-docId cases)
  • 4.14.2 FuenteMusicaLocalAuto interface + FuenteMusicaLocalAutoImpl channel-backed implementation (musica_local_auto.dart)
  • 5.15.4 + 11.1 raiz(incluirMusicaLocal:), itemsLocales, title-stripping, artUriLocal, root-count regression landed in the SAME commit/batch as the test update (never a stale-count false green)
  • 6.16.2 hijosMusicaLocal dispatch, wired into PluriWaveAudioHandler.getChildren
  • 7.17.3 reproducirPistaLocal, wired into PluriWaveAudioHandler.playFromMediaId
  • 8.18.5 Native file_actions channel: pickMusicFolder, listAudioChildren, resolvePlayableUri, hasPersistedPermission, onActivityResult override (STATIC REVIEW ONLY)
  • 9.19.4 _SeccionMusicaLocal settings UI + SharedPreferences persistence (musica_local_uri) + l10n keys (app_en.arb/app_es.arb)
  • 10.110.3 Cold-start / permission-revoked safety (explicit tests on top of the try/catch already required by tasks 4/6/7)
  • 12.1 Full regression pass of navegacion_auto_test.dart + musica_local_auto_test.dart + the 4 existing servicio_audio_*_test.dart files — 91/91 passing, no pre-existing assertion broken

Files Changed

File Action What Was Done
lib/modelos/pista_local.dart Created NodoLocal + PistaLocal pure DTOs
lib/servicios/musica_local_auto.dart Created esArchivoAudio, FuenteMusicaLocalAuto interface, FuenteMusicaLocalAutoImpl (channel-backed, cold-start-safe, SharedPreferences-DI)
lib/servicios/navegacion_auto.dart Modified _prefijoPista/esPistaMediaId (top-level); idMusicaLocal/_prefijoCarpetaLocal/_maxItemsCarpetaLocal/esCarpetaLocalMediaId/idCarpetaLocalDesde/itemsLocales/_itemLocal (on ConstructorArbolAuto); raiz(incluirMusicaLocal:) signature change; module-level artUriLocal, _tituloDesdeNombre, _tituloDesdeDocumentId, hijosMusicaLocal, reproducirPistaLocal
lib/servicios/servicio_audio.dart Modified _fuenteMusicaLocalGlobal + registrarFuenteMusicaLocal; getChildren root-flag + hijosMusicaLocal delegation branches; playFromMediaId pista: branch (2nd, mirrors the eq_preset: branch's unconditional-return shape)
lib/main.dart Modified (see Deviations) Registers FuenteMusicaLocalAutoImpl(prefs: prefs) via registrarFuenteMusicaLocal — required plumbing, not an explicit task line item
lib/pantallas/pantalla_ajustes.dart Modified New _SeccionMusicaLocal/_SeccionMusicaLocalState, registered in _AjustesContent's section list
lib/l10n/app_en.arb / app_es.arb Modified 7 new keys: localMusicSectionTitle, localMusicSectionDescription, localMusicFolderNotConfigured, localMusicFolderTitle, localMusicChoosePath, localMusicChangePath, localMusicFolderUpdated, localMusicFolderSaveError
android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt Modified (STATIC REVIEW ONLY) pendingMusicFolderResult field; pickMusicFolder/listAudioChildren/resolvePlayableUri/hasPersistedPermission cases in the existing file_actions when block; new onActivityResult override (first on this Activity); 3 new private helper methods
test/servicios/musica_local_auto_test.dart Created esArchivoAudio unit tests (4 tests)
test/servicios/navegacion_auto_test.dart Modified raiz test split into configured(5)/hidden(4) cases; new groups: esCarpetaLocalMediaId / esPistaMediaId, artUriLocal, ConstructorArbolAuto.itemsLocales, hijosMusicaLocal, reproducirPistaLocal; _FakeFuenteMusicaLocalAuto test double
openspec/changes/android-auto-local-music/tasks.md Modified All 31 checkbox items marked [x]

TDD Cycle Evidence

Task RED GREEN REFACTOR Notes
2. esArchivoAudio Wrote 4 failing tests (null/blank MIME+name, non-audio MIME, valid audio/*) before implementation existed Implemented in musica_local_auto.dart, all pass N/A — single small pure function
3. Media-id predicates Wrote collision + :-in-docId tests before adding esCarpetaLocalMediaId/esPistaMediaId/idCarpetaLocalDesde Implemented, all pass Mirrored exact shape of existing esCarpetaGrupo/esPresetMediaId — no rework needed
5. raiz/itemsLocales Rewrote the existing 4-folder raiz test into configured(5)/hidden(4) cases + wrote 8 itemsLocales cases (cap, sort, title-strip x3, art, folder-vs-file) before touching raiz()'s signature Implemented raiz(incluirMusicaLocal:), itemsLocales, _itemLocal, _tituloDesdeNombre, artUriLocal; all pass None needed Landed together with task 11 in the same edit so the suite was never red for the stale-count reason
6. hijosMusicaLocal dispatch Wrote 5 cases (root delegation, subfolder delegation, non-local id → null, null fuente → [], throwing fuente → []) before writing the function Implemented, all pass None needed
7. reproducirPistaLocal Wrote 4 cases (resolves+plays, stale id no-op, wrong-prefix no-op, structural EQ-seam regression guard) before writing the function Implemented, all pass None needed
10. Cold-start safety Covered by task 6/7's null-fuente and throwing-fuente cases above (explicit, not incidental) Already green from 6/7's implementation N/A No additional guard code was needed — the try/catch shape from tasks 4/6/7 already satisfied it
8. Native Kotlin (pickMusicFolder etc.) N/A — static-review-only, no test runner available N/A Written carefully against documented DocumentsContract/ActivityResultLauncher-era SAF patterns; balanced-brace/paren sanity check run Flagged as highest-risk, unverified at runtime (see Risks)
9. Settings UI N/A — SAF round trip is on-device-only per task 9's own note; no widget test written (would only cover layout, adds little given the channel call is stubbed either way) N/A Mirrors _SeccionGrabaciones exactly
1. Models N/A — task 1.2 explicitly says skip tests for getter-only DTOs N/A N/A

Test Results (independently re-run, exact counts)

flutter test test/servicios/navegacion_auto_test.dart test/servicios/musica_local_auto_test.dart --concurrency=1 --timeout=60s
→ 70/70 passing (66 in navegacion_auto_test.dart, 4 in musica_local_auto_test.dart)

flutter test test/servicios/servicio_audio_reconnect_test.dart test/servicios/servicio_audio_session_test.dart test/servicios/servicio_audio_source_switch_test.dart test/servicios/servicio_audio_eq_reapply_test.dart --concurrency=1 --timeout=60s
→ 21/21 passing (pre-existing suites, re-run to confirm no regression from servicio_audio.dart edits)

Total: 91/91 passing, 0 failing. Both commands were run twice in this session and produced identical counts both times.

Not run (per constraints): flutter analyze, flutter build, flutter gen-l10n, any Android/Gradle build or DHU/emulator session.

Deviations from Design

  1. lib/main.dart registration call — NOT an explicit task line item, but added anyway. Design's File Changes table lists registrarFuenteMusicaLocal's definition under servicio_audio.dart, mirroring _fuenteNavegacionGlobal/registrarFuenteNavegacion. However, nothing in tasks.md or design.md explicitly instructs wiring the actual registrarFuenteMusicaLocal(...) call site into main.dart. Without it, _fuenteMusicaLocalGlobal would always be null at runtime and the local root would never appear regardless of what the user configures — the feature would be entirely non-functional despite 100% green tests (since all pure-Dart tests inject a fake fuente directly). I added the one-line registration in main.dart, mirroring the existing registrarFuenteNavegacion(fuenteAuto) call exactly (same file, same pattern, 5 lines including the comment). This is a necessary completion of task 4/6's own intent, not a scope expansion — flagging it explicitly rather than silently expanding scope, as instructed.
  2. Local-track title on playback is derived from the SAF documentId's trailing path segment, not from a carried filename. Design's FuenteMusicaLocalAuto interface (Interfaces / Contracts) only exposes uriContenidoDePista(documentId) → content:// or null — no filename is returned alongside the content URI. Since MediaItem.title is required for the Now Playing UI, reproducirPistaLocal derives a best-effort title via _tituloDesdeDocumentId (last /-segment of the documentId, then the SAME _tituloDesdeNombre extension-stripping rule the browse tree uses). This is consistent with the design's stated title rule and requires no interface change or second native round trip, but is a genuine interpretation filling a gap in the documented contract — flagging it explicitly per the "note deviations, don't silently freelance" rule.
  3. Folder titles in itemsLocales are NOT extension-stripped — only playable track titles are. Design's "Title = filename minus extension; art = reused station_art_* rotation" bullet pairs title+art together, and art clearly only applies to playable items (folders have no artUri) — read narrowly as a track-mapping rule, not a folder-mapping rule. Folders keep their raw nombre, matching how every other non-playable folder in this file (itemGrupo, the root _carpeta calls) uses the raw label with no stripping.

Risks

  • Native Kotlin onActivityResult (task 8) is genuinely new plumbing on MainActivity and is UNVERIFIED at runtime — no Android build/DHU available in this environment. Written carefully against standard SAF ACTION_OPEN_DOCUMENT_TREE/DocumentsContract patterns (mirrors how openDirectory/viewDirectory already use DocumentsContract in this same file), with explicit handling for: stale-pending-result overwrite (user re-triggers the picker while a previous call is still pending), cancel (resultCode != RESULT_OK), null data.data, and a takePersistableUriPermission failure. This is exactly the risk flagged by both design.md's "Open Questions" and tasks.md's task-8 note — reviewer attention warranted here specifically, on-device manual verification required before this ships.
  • listAudioChildren's SAF query (task 8.2) is also unverified at runtime. Uses DocumentsContract.buildChildDocumentsUriUsingTree + ContentResolver.query with the standard 3-column projection (COLUMN_DOCUMENT_ID/COLUMN_DISPLAY_NAME/COLUMN_MIME_TYPE) — a well-documented pattern, but not exercised against a real SAF provider in this pass.
  • Deviation #2 above (derived playback title) means a played local track's Now Playing title could theoretically differ from its browse-list title if a future native change ever made documentId's trailing segment diverge from the actual filename (unlikely with the current SAF-native DocumentsContract documentId shape, but worth a note for Phase 2 if metadata is added).

Workload / PR Boundary

  • Mode: single PR, size:exception (explicit user choice, matches precedent commit 35bb180).
  • Current work unit: all of Phase 1 (task groups 112), one pass.
  • Boundary: starts from zero (no prior apply-progress existed) and ends with every task in tasks.md marked [x] and the full targeted test suite green.
  • Estimated review budget impact: tasks.md's own forecast estimated ~750950 changed lines; this implementation is within that range. Reviewer should budget for a single large review pass, with extra attention on MainActivity.kt's new onActivityResult per the Risks section above.

Status

31/31 checkbox items across 12/12 task groups complete. Working tree left unstaged for the orchestrator to commit/push with a [size:exception] tag, per instructions.

Next recommended: sdd-verify