Files
pluriwave/openspec/changes/android-auto-local-music/tasks.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

24 KiB

Tasks: Android Auto Local Music — Phase 1

Apply status: all 12 task groups complete (single-pass batch, size:exception). See openspec/changes/android-auto-local-music/apply-progress.md for the full implementation report, TDD evidence table, and deviations.

Scope: Phase 1 only (foundational plumbing). Grounded against live code as of this writing — see "Grounding notes" per task for exact file:line anchors re-verified during this pass (not trusted from spec/design alone).

Strict TDD Mode is ACTIVE for this project. Every pure-Dart, behavior-changing task below follows red → green → refactor. Native Kotlin and any on-device SAF picker flow is static-review-only (this project's established precedent — same as the Android Auto EQ-presets and browsable-tree changes) and is flagged explicitly per task. flutter build/flutter analyze/flutter gen-l10n are NOT executable tasks here — they are manual/CI follow-ups, same convention as prior archived changes.

Grounding corrections vs. spec/design (read first)

  • Root folder count test: test/servicios/navegacion_auto_test.dart:221-244 currently asserts raiz() returns exactly 4 folders (hasLength(4)), a Set of the 4 ids, and raiz.last.id == idEcualizador. This MUST become 5, same pattern as the EQ-presets change had to update this same block. Confirmed live, not assumed from design.
  • raiz() signature: lib/servicios/navegacion_auto.dart:171-176 currently takes no parameters. Design's raiz(incluirMusicaLocal: ...) is a real signature change — every existing call site of raiz() must be checked (lib/servicios/servicio_audio.dart:739 is the only call site found).
  • file_actions channel: MainActivity.kt:215-250 currently has exactly 3 methods (openDirectory, viewDirectory, openFile), all synchronous, all using startActivity (never startActivityForResult). There is no existing onActivityResult override in this Activity — confirmed via full-file read. Adding pickMusicFolder via startActivityForResult requires adding an onActivityResult override (or an ActivityResultLauncher) that does not exist today. This is new surface on MainActivity, exactly as design's "Open Questions" flags — call this out again at task level since it's the highest-risk native piece.
  • Manifest/pubspec claim — CONFIRMED correct: AndroidManifest.xml (root, 1-122) has no READ_MEDIA_AUDIO/storage permission today, and pubspec.yaml already lists file_picker: ^8.1.7 (used elsewhere for _SeccionGrabaciones's path picker, NOT for SAF tree URIs) with no shared_storage or similar. Design's "no manifest/pubspec changes" claim holds — ACTION_OPEN_DOCUMENT_TREE + takePersistableUriPermission need no manifest entry. Flagging as verified, not assumed.
  • Design deliberately does NOT reuse file_picker's getDirectoryPath() (the pattern _SeccionGrabaciones uses at pantalla_ajustes.dart:96) for the local music root — that API returns a plain path, not a URI with a persistable grant. The new _SeccionMusicaLocal therefore calls the NEW native pickMusicFolder method directly via the file_actions MethodChannel, not FilePicker.platform.
  • _SeccionGrabaciones is the closest UI precedent (pantalla_ajustes.dart:89-279): PluriGlassSurface card, Row header with icon + title, FutureBuilder for the current path, Wrap of OutlinedButton.icon/FilledButton.tonalIcon actions, ScaffoldMessenger snackbar feedback. _SeccionMusicaLocal should mirror this shape (registered in the PantallaAjustes sections list at pantalla_ajustes.dart:63-83).
  • SharedPreferences DI pattern: servicio_ecualizador.dart:37,54,57 — constructor takes an optional injected SharedPreferences? prefs, falls back to SharedPreferences.getInstance(). New code (FuenteMusicaLocalAuto impl / a settings-side service) should follow this exact injectable pattern for testability.

1. PistaLocal / NodoLocal models (pure Dart — unit-testable)

  • 1.1 Create lib/modelos/pista_local.dart with NodoLocal (documentId, nombre, esDirectorio) and PistaLocal (documentId, titulo derived-at-construction or computed, contentUri) per design's minimal Phase 1 shape — no metadata fields (artist/album/duration) per spec's "Not in this delta".
  • 1.2 Unit tests for any parsing/equality helpers on these models (if added). If the models are pure DTOs with no logic, skip — do not write tests for getter-only classes with no behavior.
  • Requirement: Local Music Browsable Tree (spec, PistaLocal resolution).
  • Parallel: yes — no dependency on other tasks.

2. esArchivoAudio — Dart-side re-validation (pure Dart — unit-testable, TDD)

  • 2.1 RED: write failing tests in a new/extended test file (or navegacion_auto_test.dart if colocated) for esArchivoAudio(mime, nombre): accepts audio/* MIME, rejects null/non-audio MIME even with an audio-like extension, rejects blank/null inputs — belt-and-suspenders per design's "Interfaces / Contracts" note (native already filters, Dart re-validates).
  • 2.2 GREEN: implement esArchivoAudio (location: navegacion_auto.dart or musica_local_auto.dart, per task 4's file placement) to pass.
  • Requirement: Local Music Browsable Tree (spec — audio files as playable items).
  • Parallel: yes, can run alongside task 1.

3. Media-id scheme — encode/decode (pure Dart — unit-testable, TDD)

  • 3.1 RED: tests for musica_local root id, carpeta_local:<docId> / pista:<docId> predicates and id-stripping, mirroring the existing esPresetMediaId/esCarpetaGrupo test patterns (navegacion_auto_test.dart:246-264). Include collision tests against emisora:, grupo:, eq_preset: and the bare folder-id constants (idFavoritos, idTodas, idMisEmisoras, idEcualizador) — same collision-free requirement the existing prefixes document at navegacion_auto.dart:14-24,149. Cover the "prefix stripped by length, not by string ops that would mangle a documentId containing :" case explicitly (design's stated rationale for length-based stripping) — pick a docId fixture containing a : (SAF documentIds commonly look like primary:Music/Local).
  • 3.2 GREEN: implement idMusicaLocal, _prefijoCarpetaLocal, _prefijoPista constants + esCarpetaLocalMediaId/esPistaMediaId predicates in navegacion_auto.dart, following the exact shape of _prefijoGrupo/esCarpetaGrupo (navegacion_auto.dart:149,225) and _prefijoPresetEq/esPresetMediaId (navegacion_auto.dart:18,24).
  • Requirement: Local Music Browsable Tree (spec — media-id scheme, collision-free).
  • Sequential: blocks tasks 5 and 7 (they route on these predicates).

4. FuenteMusicaLocalAuto abstraction + channel-backed impl

  • 4.1 RED: unit tests for a FAKE FuenteMusicaLocalAuto implementation exercising the pure orchestration logic that will consume it (folded into task 5/6's tests) — the interface itself (hayCarpetaConfigurada/hijos/uriContenidoDePista) has no logic to red/green in isolation; test it through its consumers.
  • 4.2 GREEN: create lib/servicios/musica_local_auto.dart with the FuenteMusicaLocalAuto abstract class (design's "Interfaces / Contracts") and a channel-backed implementation that calls the file_actions MethodChannel's listAudioChildren/resolvePlayableUri/ hasPersistedPermission methods (task 8's native methods), wrapping every channel call in try/catch → never-throws per design (mirrors FuenteEmisorasAutoLocal's cold-start-safe try/catch shape, navegacion_auto.dart:421-471).
  • Requirement: Local Music Root Access and Permission Persistence (spec).
  • Sequential: depends on task 8 (native method names/wire shape) for the real impl, but the interface + a FAKE impl can be written in parallel with task 8.

5. ConstructorArbolAuto / tree extension (pure Dart — unit-testable, TDD)

  • 5.1 RED: extend navegacion_auto_test.dart's group('ConstructorArbolAuto.raiz', ...) (:221-244) — the count MUST become 5, order MUST be Favoritos, Todas, Mis emisoras, Música Local, Ecualizador (Música Local now 4th, Ecualizador remains last per design ADR "local root ... placed before Ecualizador"). Add a SEPARATE test group for the hidden-until-configured case: raiz(incluirMusicaLocal: false) (or equivalent) omits the folder — 4 folders, matching the OLD assertion shape, so the "unconfigured" case is byte-identical to pre-change behavior (regression guard, same pattern as the empty-favorite-group precedent design cites).
  • 5.2 RED: tests for itemsLocales (native node list → MediaItem mapping): alphabetical sort, _maxItemsCarpetaLocal = 50 truncation cap (mirror test/.../navegacion_auto_test.dart:398's hasLength(50) pattern for the existing _maxItemsPorCarpeta cap), title = filename minus last .ext (with "whole name if no dot" and "non-empty fallback constant if blank/null" cases each as their own case), artUriLocal = existing station_art_* rotation seeded by documentId via indiceArtePara (reuse, do not reimplement — assert against artUriPara's existing rotation constant order, _nombresArte at navegacion_auto.dart:33).
  • 5.3 RED: test empty-subfolder browse returns [] not an error (spec "browsing an empty subfolder").
  • 5.4 GREEN: implement idMusicaLocal addition to raiz() (navegacion_auto.dart:171-176, now parameterized), itemsLocales(nodos), title-stripping helper, artUriLocal(documentId) reusing indiceArtePara/_nombresArte.
  • Requirement: Local Music Browsable Tree; Local Music Folder Item Cap; MODIFIED "Browsable Media Tree" (spec — 5-folder root, order, cap).
  • Sequential: depends on task 3 (predicates/constants) and task 1 (NodoLocal).

6. getChildren dispatch wiring (pure Dart — unit-testable, TDD)

  • 6.1 RED: tests (can live in a new test file exercising PluriWaveAudioHandler.getChildren the way existing tests exercise it, or as pure-function tests if the dispatch logic is extracted into navegacion_auto.dart first — prefer extraction, matching the existing "thin delegation" pattern at servicio_audio.dart:731-765) for: root request includes/excludes Música Local per hayCarpetaConfigurada(); musica_local id → fuente.hijos(''); carpeta_local:<id>fuente.hijos(id); cold-start (fuente local source unset/errors) → [], never throws, mirroring the existing root try/catch → const [] shape (servicio_audio.dart:736-764).
  • 6.2 GREEN: add the new branches to getChildren (servicio_audio.dart:731-765) — insert BEFORE the generic _listaParaCarpeta fallthrough at the bottom, same branch-ordering convention as the existing idFavoritos/esCarpetaGrupo special-cases.
  • Requirement: Local Music Browsable Tree; MODIFIED "Browsable Media Tree" — "Browse requested before app state is loaded" regression scenario.
  • Sequential: depends on tasks 3, 4, 5.

7. playFromMediaId wiring for pista:<docId> (pure Dart — unit-testable, TDD)

  • 7.1 RED: tests for reproducirPistaLocal (or equivalently named function, mirroring reproducirPorMediaId's shape at navegacion_auto.dart:301-325): resolves via fuente.uriContenidoDePista(docId), builds a MediaItem and delegates to an injected reproducir callback; stale/unknown docId (uriContenidoDePista returns null) is a no-op — reproducir is never called, no exception (spec "Unknown or stale track id"). Same fake-callback test shape as the existing reproducirPorMediaId tests.
  • 7.2 RED: EQ regression-guard test (spec "EQ still applies to local track playback") — asserts the local-track play path calls the SAME playMediaItem injection point stations use, with no separate/bypassed path. This can be asserted structurally (same signature shape as reproducirPorMediaId, no alternate EQ seam) plus a wiring test at the PluriWaveAudioHandler.playFromMediaId level confirming pista: routes into playMediaItem exactly like the existing emisora: branch at servicio_audio.dart:805-811.
  • 7.3 GREEN: implement reproducirPistaLocal in navegacion_auto.dart; wire the pista: branch into playFromMediaId (servicio_audio.dart:781-820) — insert as a branch parallel to the existing esPresetMediaId(...) early-return (servicio_audio.dart:792-804) and the trailing reproducirPorMediaId call, preserving the existing outer try/catch (:786-819) so a thrown resolution error still can't propagate from the handler.
  • Requirement: Local Track Playback Reuses Existing Pipeline (spec, all 3 scenarios).
  • Sequential: depends on tasks 3 and 4.

8. Native file_actions channel extension (Kotlin — static-review-only)

  • 8.1 STATIC REVIEW ONLY. Add pickMusicFolder to MainActivity.kt's file_actions handler (:218-250): launch Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) via startActivityForResult (NEW to this Activity — no existing onActivityResult override exists today, confirmed via full-file read; this task must ADD one), call contentResolver.takePersistableUriPermission(uri, FLAG_GRANT_READ_URI_PERMISSION) on result, and return the picked tree URI (or null on cancel) back to Dart via the pending MethodChannel.Result held across the activity-result round trip. Follow the existing result.success(...) / Log.d(tag, "file_actions.<method> ...") conventions used by openDirectory/viewDirectory/openFile.
  • 8.2 STATIC REVIEW ONLY. Add listAudioChildren(treeUri, parentDocumentId): resolve the tree via DocumentFile.fromTreeUri, walk ONE level (lazy, per design "never an eager tree dump"), filter files to audio/* MIME, return [{documentId, nombre, esDirectorio}].
  • 8.3 STATIC REVIEW ONLY. Add resolvePlayableUri(treeUri, documentId): resolve a leaf documentId to its playable content:// URI.
  • 8.4 STATIC REVIEW ONLY. Add hasPersistedPermission(treeUri): checks contentResolver.persistedUriPermissions for the stored tree URI, used for cold-start / revoked-permission detection (task 9).
  • 8.5 STATIC REVIEW ONLY. Register all 4 new methods in the existing when (call.method) block (:219-249), preserving the existing else -> result.notImplemented() fallthrough.
  • Requirement: Local Music Root Access and Permission Persistence (spec, all 3 scenarios); Local Music Browsable Tree.
  • Parallel: independent of the Dart tasks above except for wire-shape agreement with task 4's channel-call argument/return names — coordinate field names (documentId, nombre, esDirectorio) exactly between 4.2 and 8.2/8.3.
  • FLAG: highest native risk in this delta — startActivityForResult + onActivityResult is genuinely new plumbing on MainActivity, cannot be runtime-verified in this pass (design's own "Open Questions" says the same). Reviewer should pay particular attention to: result-code handling on user cancel, and correctly returning to the SAME pending MethodChannel.Result (not a stale one) if the user backgrounds the app during the picker.

9. Phone-side settings UI — _SeccionMusicaLocal (Flutter widget — manual/limited-test)

  • 9.1 Create _SeccionMusicaLocal in pantalla_ajustes.dart, mirroring _SeccionGrabaciones's shape (:89-279): PluriGlassSurface card, FutureBuilder-driven current-folder display (or "not configured" state), a "Choose folder" OutlinedButton.icon that invokes the native pickMusicFolder channel method directly (NOT FilePicker.platform — see "Grounding corrections"), snackbar feedback via ScaffoldMessenger following the exact try/catch/snackbar shape at :100-111.
  • 9.2 Persist the picked tree URI to SharedPreferences under musica_local_uri, using the injectable-prefs pattern from servicio_ecualizador.dart:37,54,57 (constructor-injected SharedPreferences?, falls back to .getInstance()) — whichever service/class owns this read/write (likely FuenteMusicaLocalAuto's concrete impl or a small dedicated settings service).
  • 9.3 Register _SeccionMusicaLocal() in PantallaAjustes's section list (pantalla_ajustes.dart:63-83) — placement is a phone-UI decision, not constrained by the car's root-folder ordering; place near _SeccionGrabaciones given the shared "local files" theme.
  • 9.4 Add new l10n keys to lib/l10n/app_en.arb and lib/l10n/app_es.arb (folder-picker dialog title, "not configured" state text, success/error snackbar text — mirroring recordingsFolderDialogTitle, recordingsPathUpdated, recordingsPathSaveError keys). The other 12 locale .arb files (app_ru.arb, app_zh.arb, app_ja.arb, app_pt.arb, app_fr.arb, app_hi.arb, app_id.arb, app_it.arb, app_de.arb, app_ar.arb, app_bn.arb, plus gen-l10n regeneration of lib/l10n/gen/*) are DEVIATED / manual follow-up — same convention as prior archived changes, NOT an executable task here.
  • Requirement: Local Music Root Access and Permission Persistence (spec, "User picks a local music root folder").
  • Sequential: depends on task 8 (channel method must exist for 9.1 to call) — but the widget SHELL/layout can be built against a stubbed channel call in parallel with task 8's implementation.
  • Note: this is Flutter widget code with a native-channel side effect and an actual SAF picker dialog — genuinely on-device-only verification for the full picker flow (same as task 8's flag). The widget layout/state-management logic itself can get light flutter_test widget-test coverage (folder-display states, button presence) if useful, but the SAF round-trip cannot be unit tested — call this out in the PR description.

10. Cold-start / permission-revoked safety (pure Dart — unit-testable, TDD)

  • 10.1 RED: tests asserting hayCarpetaConfigurada() returning false (no folder ever picked) yields raiz() WITHOUT the Música Local folder — same assertion as task 5.1's hidden-folder case, cross-referenced here for the "never picked" scenario specifically (spec "Permission revoked or never granted", first half).
  • 10.2 RED: tests asserting a FAKE FuenteMusicaLocalAuto whose hijos()/uriContenidoDePista() simulate a revoked-permission failure (channel throws or returns empty) degrade to []/null — never throws out of getChildren/playFromMediaId — mirroring FuenteEmisorasAutoLocal.favoritos()'s try/catch → const [] cold-start pattern (navegacion_auto.dart:421-431).
  • 10.3 GREEN: any missing guard clauses from tasks 4/6/7 to satisfy 10.1/10.2 (should mostly already be covered if those tasks' try/catch wrapping is done correctly — this task exists to make the safety net EXPLICIT and independently tested, not just incidentally covered).
  • Requirement: Local Music Root Access and Permission Persistence (spec, "Permission revoked or never granted"); MODIFIED "Browsable Media Tree" ("Browse requested before app state is loaded").
  • Sequential: depends on tasks 4, 5, 6, 7.

11. Root-folder-count regression update (pure Dart — TDD, explicit "don't forget")

  • 11.1 DO NOT SKIP — same trap as the EQ-presets change: update test/servicios/navegacion_auto_test.dart:221-244's group('ConstructorArbolAuto.raiz', ...) from asserting 4 folders to 5 (configured case) — already covered by task 5.1, listed here again standalone so it cannot be silently dropped if task 5 is split across commits/PRs.
  • Requirement: MODIFIED "Browsable Media Tree" — "Car requests the root" scenario.
  • Sequential: must land in the SAME commit as task 5's raiz() change (a green-but-stale test count is a false-positive regression risk otherwise).

12. Full regression pass (pure Dart — unit-testable, run don't write)

  • 12.1 Run the full navegacion_auto_test.dart suite plus any servicio_audio_test.dart/EQ-related tests after tasks 1-11 land — confirm no existing radio/favorite-groups/EQ-presets assertions broke from the new 5th folder or the extended getChildren/playFromMediaId dispatch. This is a verification run, not new test-writing — flag any break found as a task-11-adjacent fix, not a new task.
  • Requirement: all existing spec requirements (regression guard, implicit).
  • Sequential: last — depends on everything above.

Deviated / manual follow-up (not executable tasks here)

  • flutter analyze, flutter build, flutter gen-l10n — CI/manual, same convention as prior archived changes.
  • On-device manual verification of the SAF folder-picker flow (task 8/9) — cannot be unit tested, requires an actual Android Auto head unit or emulator + a real device folder with audio files.
  • 12 non-English/non-Spanish .arb locale files (task 9.4) — translation is out of scope for this delta.

Review Workload Forecast

Estimated changed lines: ~750-950 (additions + deletions), across:

Area File(s) Est. lines
New Dart model lib/modelos/pista_local.dart ~30-50
New Dart service lib/servicios/musica_local_auto.dart ~90-130
Modified Dart lib/servicios/navegacion_auto.dart ~120-170 (new constants, predicates, raiz() signature change, itemsLocales, reproducirPistaLocal, title/art helpers)
Modified Dart lib/servicios/servicio_audio.dart ~40-60 (getChildren branches, playFromMediaId branch)
Modified Dart lib/pantallas/pantalla_ajustes.dart ~130-180 (new _SeccionMusicaLocal class, mirrors _SeccionGrabaciones's ~190 lines but narrower scope)
New Kotlin MainActivity.kt ~120-170 (4 new methods + onActivityResult override + pending-result plumbing — genuinely new to this file)
Tests navegacion_auto_test.dart + new test file(s) ~180-250
l10n app_en.arb, app_es.arb (+ generated) ~20-30

Chained PRs recommended: Yes. This is the largest and most structurally novel change this session — it is the first delta in this project that adds NEW native platform-channel surface (startActivityForResult/onActivityResult, absent today) rather than extending an already-lazy, already-tested Dart dispatch pattern alone (unlike the EQ-presets and browsable-tree changes, which were pure-Dart extensions of existing seams). Combined with a new settings UI screen section and a new Dart model, a single PR is very likely to exceed the 400-line budget and mixes three independently reviewable/rollback-able concerns (pure-Dart tree logic, native channel, phone UI).

400-line budget risk: High.

Suggested slice boundaries (if delivery_strategy calls for chaining):

  1. Tasks 1-3 (models + esArchivoAudio + media-id scheme) — pure Dart, small, independently mergeable, ~150-200 lines.
  2. Tasks 4-7 (Dart orchestration: FuenteMusicaLocalAuto interface, tree extension, getChildren/playFromMediaId wiring, cold-start safety, test updates) — pure Dart, the bulk of the testable logic, ~350-450 lines. Depends on slice 1.
  3. Task 8 (native Kotlin channel extension) — static-review-only, isolated rollback surface, ~120-170 lines. Can be built in parallel with slice 2 but should be its OWN PR given the review-attention flag on startActivityForResult/onActivityResult.
  4. Task 9 (settings UI) — depends on slice 3's channel methods existing; ~150-210 lines including l10n.

Decision needed before apply: Yes — recommend sdd-apply be scoped to ONE slice at a time per the orchestrator's Review Workload Guard, using the cached delivery_strategy/chain_strategy, rather than attempting all of Phase 1 in a single work session/PR.