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.
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-244currently assertsraiz()returns exactly 4 folders (hasLength(4)), aSetof the 4 ids, andraiz.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-176currently takes no parameters. Design'sraiz(incluirMusicaLocal: ...)is a real signature change — every existing call site ofraiz()must be checked (lib/servicios/servicio_audio.dart:739is the only call site found).file_actionschannel:MainActivity.kt:215-250currently has exactly 3 methods (openDirectory,viewDirectory,openFile), all synchronous, all usingstartActivity(neverstartActivityForResult). There is no existingonActivityResultoverride in this Activity — confirmed via full-file read. AddingpickMusicFolderviastartActivityForResultrequires adding anonActivityResultoverride (or anActivityResultLauncher) that does not exist today. This is new surface onMainActivity, 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 noREAD_MEDIA_AUDIO/storage permission today, andpubspec.yamlalready listsfile_picker: ^8.1.7(used elsewhere for_SeccionGrabaciones's path picker, NOT for SAF tree URIs) with noshared_storageor similar. Design's "no manifest/pubspec changes" claim holds —ACTION_OPEN_DOCUMENT_TREE+takePersistableUriPermissionneed no manifest entry. Flagging as verified, not assumed. - Design deliberately does NOT reuse
file_picker'sgetDirectoryPath()(the pattern_SeccionGrabacionesuses atpantalla_ajustes.dart:96) for the local music root — that API returns a plain path, not a URI with a persistable grant. The new_SeccionMusicaLocaltherefore calls the NEW nativepickMusicFoldermethod directly via thefile_actionsMethodChannel, notFilePicker.platform. _SeccionGrabacionesis the closest UI precedent (pantalla_ajustes.dart:89-279):PluriGlassSurfacecard,Rowheader with icon + title,FutureBuilderfor the current path,WrapofOutlinedButton.icon/FilledButton.tonalIconactions,ScaffoldMessengersnackbar feedback._SeccionMusicaLocalshould mirror this shape (registered in thePantallaAjustessections list atpantalla_ajustes.dart:63-83).- SharedPreferences DI pattern:
servicio_ecualizador.dart:37,54,57— constructor takes an optional injectedSharedPreferences? prefs, falls back toSharedPreferences.getInstance(). New code (FuenteMusicaLocalAutoimpl / 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.dartwithNodoLocal(documentId,nombre,esDirectorio) andPistaLocal(documentId,tituloderived-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,
PistaLocalresolution). - 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.dartif colocated) foresArchivoAudio(mime, nombre): acceptsaudio/*MIME, rejectsnull/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.dartormusica_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_localroot id,carpeta_local:<docId>/pista:<docId>predicates and id-stripping, mirroring the existingesPresetMediaId/esCarpetaGrupotest patterns (navegacion_auto_test.dart:246-264). Include collision tests againstemisora:,grupo:,eq_preset:and the bare folder-id constants (idFavoritos,idTodas,idMisEmisoras,idEcualizador) — same collision-free requirement the existing prefixes document atnavegacion_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 likeprimary:Music/Local). - 3.2 GREEN: implement
idMusicaLocal,_prefijoCarpetaLocal,_prefijoPistaconstants +esCarpetaLocalMediaId/esPistaMediaIdpredicates innavegacion_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
FuenteMusicaLocalAutoimplementation 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.dartwith theFuenteMusicaLocalAutoabstract class (design's "Interfaces / Contracts") and a channel-backed implementation that calls thefile_actionsMethodChannel'slistAudioChildren/resolvePlayableUri/hasPersistedPermissionmethods (task 8's native methods), wrapping every channel call in try/catch → never-throws per design (mirrorsFuenteEmisorasAutoLocal'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'sgroup('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 →MediaItemmapping): alphabetical sort,_maxItemsCarpetaLocal = 50truncation cap (mirrortest/.../navegacion_auto_test.dart:398'shasLength(50)pattern for the existing_maxItemsPorCarpetacap), 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= existingstation_art_*rotation seeded bydocumentIdviaindiceArtePara(reuse, do not reimplement — assert againstartUriPara's existing rotation constant order,_nombresArteatnavegacion_auto.dart:33). - 5.3 RED: test empty-subfolder browse returns
[]not an error (spec "browsing an empty subfolder"). - 5.4 GREEN: implement
idMusicaLocaladdition toraiz()(navegacion_auto.dart:171-176, now parameterized),itemsLocales(nodos), title-stripping helper,artUriLocal(documentId)reusingindiceArtePara/_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.getChildrenthe way existing tests exercise it, or as pure-function tests if the dispatch logic is extracted intonavegacion_auto.dartfirst — prefer extraction, matching the existing "thin delegation" pattern atservicio_audio.dart:731-765) for: root request includes/excludes Música Local perhayCarpetaConfigurada();musica_localid →fuente.hijos('');carpeta_local:<id>→fuente.hijos(id); cold-start (fuentelocal source unset/errors) →[], never throws, mirroring the existing roottry/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_listaParaCarpetafallthrough at the bottom, same branch-ordering convention as the existingidFavoritos/esCarpetaGrupospecial-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, mirroringreproducirPorMediaId's shape atnavegacion_auto.dart:301-325): resolves viafuente.uriContenidoDePista(docId), builds aMediaItemand delegates to an injectedreproducircallback; stale/unknown docId (uriContenidoDePistareturnsnull) is a no-op —reproduciris never called, no exception (spec "Unknown or stale track id"). Same fake-callback test shape as the existingreproducirPorMediaIdtests. - 7.2 RED: EQ regression-guard test (spec "EQ still applies to local track
playback") — asserts the local-track play path calls the SAME
playMediaIteminjection point stations use, with no separate/bypassed path. This can be asserted structurally (same signature shape asreproducirPorMediaId, no alternate EQ seam) plus a wiring test at thePluriWaveAudioHandler.playFromMediaIdlevel confirmingpista:routes intoplayMediaItemexactly like the existingemisora:branch atservicio_audio.dart:805-811. - 7.3 GREEN: implement
reproducirPistaLocalinnavegacion_auto.dart; wire thepista:branch intoplayFromMediaId(servicio_audio.dart:781-820) — insert as a branch parallel to the existingesPresetMediaId(...)early-return (servicio_audio.dart:792-804) and the trailingreproducirPorMediaIdcall, 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
pickMusicFoldertoMainActivity.kt'sfile_actionshandler (:218-250): launchIntent(Intent.ACTION_OPEN_DOCUMENT_TREE)viastartActivityForResult(NEW to this Activity — no existingonActivityResultoverride exists today, confirmed via full-file read; this task must ADD one), callcontentResolver.takePersistableUriPermission(uri, FLAG_GRANT_READ_URI_PERMISSION)on result, and return the picked tree URI (ornullon cancel) back to Dart via the pendingMethodChannel.Resultheld across the activity-result round trip. Follow the existingresult.success(...)/Log.d(tag, "file_actions.<method> ...")conventions used byopenDirectory/viewDirectory/openFile. - 8.2 STATIC REVIEW ONLY. Add
listAudioChildren(treeUri, parentDocumentId): resolve the tree viaDocumentFile.fromTreeUri, walk ONE level (lazy, per design "never an eager tree dump"), filter files toaudio/*MIME, return[{documentId, nombre, esDirectorio}]. - 8.3 STATIC REVIEW ONLY. Add
resolvePlayableUri(treeUri, documentId): resolve a leaf documentId to its playablecontent://URI. - 8.4 STATIC REVIEW ONLY. Add
hasPersistedPermission(treeUri): checkscontentResolver.persistedUriPermissionsfor 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 existingelse -> 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+onActivityResultis genuinely new plumbing onMainActivity, 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 pendingMethodChannel.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
_SeccionMusicaLocalinpantalla_ajustes.dart, mirroring_SeccionGrabaciones's shape (:89-279):PluriGlassSurfacecard,FutureBuilder-driven current-folder display (or "not configured" state), a "Choose folder"OutlinedButton.iconthat invokes the nativepickMusicFolderchannel method directly (NOTFilePicker.platform— see "Grounding corrections"), snackbar feedback viaScaffoldMessengerfollowing 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 fromservicio_ecualizador.dart:37,54,57(constructor-injectedSharedPreferences?, falls back to.getInstance()) — whichever service/class owns this read/write (likelyFuenteMusicaLocalAuto's concrete impl or a small dedicated settings service). - 9.3 Register
_SeccionMusicaLocal()inPantallaAjustes'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_SeccionGrabacionesgiven the shared "local files" theme. - 9.4 Add new l10n keys to
lib/l10n/app_en.arbandlib/l10n/app_es.arb(folder-picker dialog title, "not configured" state text, success/error snackbar text — mirroringrecordingsFolderDialogTitle,recordingsPathUpdated,recordingsPathSaveErrorkeys). The other 12 locale.arbfiles (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, plusgen-l10nregeneration oflib/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_testwidget-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()returningfalse(no folder ever picked) yieldsraiz()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
FuenteMusicaLocalAutowhosehijos()/uriContenidoDePista()simulate a revoked-permission failure (channel throws or returns empty) degrade to[]/null— never throws out ofgetChildren/playFromMediaId— mirroringFuenteEmisorasAutoLocal.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'sgroup('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.dartsuite plus anyservicio_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 extendedgetChildren/playFromMediaIddispatch. 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
.arblocale 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):
- Tasks 1-3 (models +
esArchivoAudio+ media-id scheme) — pure Dart, small, independently mergeable, ~150-200 lines. - Tasks 4-7 (Dart orchestration:
FuenteMusicaLocalAutointerface, tree extension,getChildren/playFromMediaIdwiring, cold-start safety, test updates) — pure Dart, the bulk of the testable logic, ~350-450 lines. Depends on slice 1. - 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. - 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.