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

171 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.11.2 `PistaLocal`/`NodoLocal` models (pure DTOs — no tests written,
per task 1.2's explicit "getter-only classes, skip" rule)
- [x] 2.12.2 `esArchivoAudio` (RED → GREEN, `musica_local_auto.dart`)
- [x] 3.13.2 Media-id scheme: `idMusicaLocal`, `_prefijoCarpetaLocal`,
`_prefijoPista`, `esCarpetaLocalMediaId`, `esPistaMediaId`,
`idCarpetaLocalDesde` (RED → GREEN, collision + `:`-in-docId cases)
- [x] 4.14.2 `FuenteMusicaLocalAuto` interface + `FuenteMusicaLocalAutoImpl`
channel-backed implementation (`musica_local_auto.dart`)
- [x] 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)
- [x] 6.16.2 `hijosMusicaLocal` dispatch, wired into
`PluriWaveAudioHandler.getChildren`
- [x] 7.17.3 `reproducirPistaLocal`, wired into
`PluriWaveAudioHandler.playFromMediaId`
- [x] 8.18.5 Native `file_actions` channel: `pickMusicFolder`,
`listAudioChildren`, `resolvePlayableUri`, `hasPersistedPermission`,
`onActivityResult` override (STATIC REVIEW ONLY)
- [x] 9.19.4 `_SeccionMusicaLocal` settings UI + SharedPreferences
persistence (`musica_local_uri`) + l10n keys (`app_en.arb`/`app_es.arb`)
- [x] 10.110.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 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`