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.
121 lines
7.0 KiB
Markdown
121 lines
7.0 KiB
Markdown
# Design: Android Auto Local Music — Phase 1
|
|
|
|
## Technical Approach
|
|
|
|
Extend the existing screaming-architecture seams, do NOT fork them. Local music becomes a
|
|
new browse domain that slots into the SAME lazy `getChildren`/`playFromMediaId` dispatch used
|
|
by stations, groups and EQ presets. All routing, id parsing, filename→title mapping, cap and
|
|
fallback-art logic lives in pure Dart in `navegacion_auto.dart` (fully unit-testable). Native
|
|
Kotlin stays thin and static-review-only: it just walks ONE `DocumentFile` level on demand and
|
|
returns a serializable node list, mirroring the already-lazy per-folder browse model.
|
|
|
|
## Architecture Decisions
|
|
|
|
### Decision: Hand-rolled SAF channel, not `shared_storage`
|
|
|
|
**Choice**: Extend the existing `pluriwave/file_actions` `MethodChannel` in `MainActivity.kt`
|
|
with lazy per-level SAF methods. **Rejected**: adding `shared_storage` (or similar).
|
|
**Rationale**: `shared_storage` is unmaintained (dependency-vetting risk); native is
|
|
static-review-only EITHER way here, so the package buys no testability. Hand-rolling gives full
|
|
control of the wire shape, lets us filter audio at the native layer (lean payload), and adds
|
|
ZERO new pub dependencies. The channel already speaks `DocumentsContract` — this is a natural
|
|
extension, not new surface.
|
|
|
|
### Decision: Lazy per-folder enumeration, never an eager tree dump
|
|
|
|
**Choice**: `listAudioChildren(treeUri, parentDocumentId)` returns ONE level (subfolders +
|
|
audio files). **Rejected**: eager recursive JSON of the whole tree. **Rationale**: libraries
|
|
reach thousands of files; a full tree round-trip is slow and memory-heavy. The existing
|
|
`getChildren` is already lazy per folder tap — one native call per browsed level mirrors it
|
|
exactly, bounds latency/memory to a single folder, and naturally respects the row cap.
|
|
|
|
### Decision: Pure SAF, no `READ_MEDIA_AUDIO`, no `permission_handler`
|
|
|
|
**Choice**: `ACTION_OPEN_DOCUMENT_TREE` + `takePersistableUriPermission` only. **Rejected**:
|
|
`READ_MEDIA_AUDIO`/`MediaStore` + `permission_handler`. **Rationale**: a persisted tree grant
|
|
reads everything under the picked root with NO dangerous runtime permission, is scoped-storage
|
|
compliant, needs no Play-Store data-access justification, and leaves the manifest permission set
|
|
UNCHANGED. Net: no new manifest permission, no runtime-request flow, no new Dart dep.
|
|
|
|
### Decision: Media-id scheme `musica_local` / `carpeta_local:` / `pista:`
|
|
|
|
**Choice**: root folder id `musica_local`; subfolders `carpeta_local:<documentId>`; tracks
|
|
`pista:<documentId>`. Prefix stripped by length so the raw `documentId` (which itself contains
|
|
`:`/`/`) survives verbatim. **Rationale**: collision-free against `emisora:`, `grupo:`,
|
|
`eq_preset:` and the bare folder ids. Playback content URI is resolved lazily at play time via
|
|
the source, so ids stay short.
|
|
|
|
### Decision: Local root hidden until a folder is configured; placed before Ecualizador
|
|
|
|
**Choice**: order = Favoritos, Todas, Mis emisoras, **Música Local**, Ecualizador; the local
|
|
folder is OMITTED from `raiz()` when no folder is persisted. **Rationale**: content-browsing
|
|
folders lead, the EQ tool trails (existing ADR-2); hiding an unconfigured root mirrors the
|
|
empty-group hidden-folder precedent (no dead ends).
|
|
|
|
### Decision: Dedicated 50-item cap, alphabetical truncation
|
|
|
|
**Choice**: separate `_maxItemsCarpetaLocal = 50`, sort by filename, truncate. **Rejected**:
|
|
higher/unbounded cap. **Rationale**: driver-distraction parity with stations; pagination is
|
|
explicitly deferred (Phase 2/3). Separate constant is the extension point (native call can later
|
|
take a page offset). No metadata in Phase 1, so ordering is alphabetical, deterministic, stable.
|
|
|
|
### Decision: Title = filename minus extension; art = reused `station_art_*` rotation
|
|
|
|
**Choice**: `titulo` = display name with the last `.ext` stripped (whole name if no dot;
|
|
non-empty fallback constant if blank/null). `artUri` = the existing 4-asset rotation seeded by
|
|
`documentId` via the existing `indiceArtePara`. **Rationale**: zero new assets, on-brand,
|
|
deterministic per-track art, reuses tested rotation infra. A distinct local-track placeholder is
|
|
deferred polish.
|
|
|
|
## Data Flow
|
|
|
|
Phone Settings ──pickMusicFolder──▶ SAF picker ──persist──▶ SharedPreferences('musica_local_uri')
|
|
Car browse root ─▶ raiz(incluirMusicaLocal: fuente.hayCarpetaConfigurada())
|
|
Car taps Música Local / carpeta_local:<id> ─▶ fuente.hijos(docId) ─▶ itemsLocales(nodos) [native lists 1 level]
|
|
Car taps pista:<id> ─▶ reproducirPistaLocal ─▶ fuente.uriContenido(docId) ─▶ playMediaItem(content:// item)
|
|
|
|
## File Changes
|
|
|
|
| File | Action | Description |
|
|
|------|--------|-------------|
|
|
| `lib/modelos/pista_local.dart` | Create | `PistaLocal` + `NodoLocal` DTO (documentId, name, isDirectory) |
|
|
| `lib/servicios/musica_local_auto.dart` | Create | `FuenteMusicaLocalAuto` abstraction + channel-backed impl (cold-start safe, never throws) |
|
|
| `lib/servicios/navegacion_auto.dart` | Modify | Add `carpeta_local:`/`pista:` predicates, `titulo`, `artUriLocal`, `itemsLocales`, `reproducirPistaLocal`; `raiz(incluirMusicaLocal:)` |
|
|
| `lib/servicios/servicio_audio.dart` | Modify | Register `_fuenteMusicaLocalGlobal`; new `getChildren` branches (root flag, `musica_local`, `carpeta_local:`) + `playFromMediaId` `pista:` branch |
|
|
| `lib/pantallas/pantalla_ajustes.dart` | Modify | New `_SeccionMusicaLocal` folder-pick section (mirrors `_SeccionGrabaciones`) |
|
|
| `android/.../MainActivity.kt` | Modify | Add `pickMusicFolder`, `listAudioChildren`, `resolvePlayableUri`, `hasPersistedPermission` to `file_actions` channel (static-review-only) |
|
|
|
|
Manifest and `pubspec.yaml`: NO changes required (pure-SAF, no new dep/permission).
|
|
|
|
## Interfaces / Contracts
|
|
|
|
```dart
|
|
class NodoLocal { final String documentId; final String nombre; final bool esDirectorio; }
|
|
|
|
abstract class FuenteMusicaLocalAuto {
|
|
Future<bool> hayCarpetaConfigurada();
|
|
Future<List<NodoLocal>> hijos(String documentId); // '' = tree root; never throws
|
|
Future<String?> uriContenidoDePista(String documentId); // content:// or null
|
|
}
|
|
```
|
|
|
|
Native `listAudioChildren` returns `[{documentId, nombre, esDirectorio}]`, filtering files to
|
|
`audio/*` MIME; Dart re-validates via pure `esArchivoAudio(mime, nombre)` (defense + testable).
|
|
|
|
## Testing Strategy
|
|
|
|
| Layer | What to Test | Approach |
|
|
|-------|-------------|----------|
|
|
| Unit | id predicates, `titulo` (ext strip / no-dot / blank), `artUriLocal`, `itemsLocales` cap+map, `raiz` visibility, `reproducirPistaLocal` (stale/unknown = no-op), cold-start empty | Pure Dart `flutter test`, fake `FuenteMusicaLocalAuto` |
|
|
| Static review | 4 new Kotlin channel methods, SAF persist/re-validate | No Android build here — code review only |
|
|
|
|
## Migration / Rollout
|
|
|
|
No migration. Additive behind a hidden root that only appears once a folder is picked. Rollback
|
|
= remove the local branches/files + the settings section; stations untouched.
|
|
|
|
## Open Questions
|
|
|
|
- None blocking. Native `pickMusicFolder` uses `startActivityForResult` (new to this Activity) —
|
|
flagged for careful static review since it cannot be runtime-verified here.
|