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.
13 KiB
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.1–1.2
PistaLocal/NodoLocalmodels (pure DTOs — no tests written, per task 1.2's explicit "getter-only classes, skip" rule) - 2.1–2.2
esArchivoAudio(RED → GREEN,musica_local_auto.dart) - 3.1–3.2 Media-id scheme:
idMusicaLocal,_prefijoCarpetaLocal,_prefijoPista,esCarpetaLocalMediaId,esPistaMediaId,idCarpetaLocalDesde(RED → GREEN, collision +:-in-docId cases) - 4.1–4.2
FuenteMusicaLocalAutointerface +FuenteMusicaLocalAutoImplchannel-backed implementation (musica_local_auto.dart) - 5.1–5.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.1–6.2
hijosMusicaLocaldispatch, wired intoPluriWaveAudioHandler.getChildren - 7.1–7.3
reproducirPistaLocal, wired intoPluriWaveAudioHandler.playFromMediaId - 8.1–8.5 Native
file_actionschannel:pickMusicFolder,listAudioChildren,resolvePlayableUri,hasPersistedPermission,onActivityResultoverride (STATIC REVIEW ONLY) - 9.1–9.4
_SeccionMusicaLocalsettings UI + SharedPreferences persistence (musica_local_uri) + l10n keys (app_en.arb/app_es.arb) - 10.1–10.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 existingservicio_audio_*_test.dartfiles — 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
lib/main.dartregistration call — NOT an explicit task line item, but added anyway. Design's File Changes table listsregistrarFuenteMusicaLocal's definition underservicio_audio.dart, mirroring_fuenteNavegacionGlobal/registrarFuenteNavegacion. However, nothing in tasks.md or design.md explicitly instructs wiring the actualregistrarFuenteMusicaLocal(...)call site intomain.dart. Without it,_fuenteMusicaLocalGlobalwould always benullat 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 inmain.dart, mirroring the existingregistrarFuenteNavegacion(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.- Local-track title on playback is derived from the SAF
documentId's trailing path segment, not from a carried filename. Design'sFuenteMusicaLocalAutointerface (Interfaces / Contracts) only exposesuriContenidoDePista(documentId) → content:// or null— no filename is returned alongside the content URI. SinceMediaItem.titleis required for the Now Playing UI,reproducirPistaLocalderives a best-effort title via_tituloDesdeDocumentId(last/-segment of the documentId, then the SAME_tituloDesdeNombreextension-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. - Folder titles in
itemsLocalesare 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 noartUri) — read narrowly as a track-mapping rule, not a folder-mapping rule. Folders keep their rawnombre, matching how every other non-playable folder in this file (itemGrupo, the root_carpetacalls) uses the raw label with no stripping.
Risks
- Native Kotlin
onActivityResult(task 8) is genuinely new plumbing onMainActivityand is UNVERIFIED at runtime — no Android build/DHU available in this environment. Written carefully against standard SAFACTION_OPEN_DOCUMENT_TREE/DocumentsContractpatterns (mirrors howopenDirectory/viewDirectoryalready useDocumentsContractin 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), nulldata.data, and atakePersistableUriPermissionfailure. 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. UsesDocumentsContract.buildChildDocumentsUriUsingTree+ContentResolver.querywith 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-nativeDocumentsContractdocumentId 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 commit35bb180). - Current work unit: all of Phase 1 (task groups 1–12), one pass.
- Boundary: starts from zero (no prior apply-progress existed) and ends
with every task in
tasks.mdmarked[x]and the full targeted test suite green. - Estimated review budget impact: tasks.md's own forecast estimated
~750–950 changed lines; this implementation is within that range. Reviewer
should budget for a single large review pass, with extra attention on
MainActivity.kt's newonActivityResultper 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