# 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 - [x] 1.1–1.2 `PistaLocal`/`NodoLocal` models (pure DTOs — no tests written, per task 1.2's explicit "getter-only classes, skip" rule) - [x] 2.1–2.2 `esArchivoAudio` (RED → GREEN, `musica_local_auto.dart`) - [x] 3.1–3.2 Media-id scheme: `idMusicaLocal`, `_prefijoCarpetaLocal`, `_prefijoPista`, `esCarpetaLocalMediaId`, `esPistaMediaId`, `idCarpetaLocalDesde` (RED → GREEN, collision + `:`-in-docId cases) - [x] 4.1–4.2 `FuenteMusicaLocalAuto` interface + `FuenteMusicaLocalAutoImpl` channel-backed implementation (`musica_local_auto.dart`) - [x] 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) - [x] 6.1–6.2 `hijosMusicaLocal` dispatch, wired into `PluriWaveAudioHandler.getChildren` - [x] 7.1–7.3 `reproducirPistaLocal`, wired into `PluriWaveAudioHandler.playFromMediaId` - [x] 8.1–8.5 Native `file_actions` channel: `pickMusicFolder`, `listAudioChildren`, `resolvePlayableUri`, `hasPersistedPermission`, `onActivityResult` override (STATIC REVIEW ONLY) - [x] 9.1–9.4 `_SeccionMusicaLocal` settings UI + SharedPreferences persistence (`musica_local_uri`) + l10n keys (`app_en.arb`/`app_es.arb`) - [x] 10.1–10.3 Cold-start / permission-revoked safety (explicit tests on top of the try/catch already required by tasks 4/6/7) - [x] 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 1–12), 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 ~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 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`