Files
pluriwave/lib/servicios/musica_local_auto.dart
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

181 lines
7.3 KiB
Dart

import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../modelos/pista_local.dart';
/// SharedPreferences key for the persisted local-music root tree URI
/// (Design "Data Flow"). Read/written exclusively by
/// [FuenteMusicaLocalAutoImpl].
const _keyUriCarpetaLocal = 'musica_local_uri';
/// Dart-side re-validation of a native-reported MIME type (Design
/// "Interfaces / Contracts" — native already filters to `audio/*`; this is
/// defense-in-depth, not the only gate). Requires a non-blank `audio/*`
/// [mime] AND a non-blank [nombre] — a blank filename is never a valid
/// audio entry regardless of MIME.
bool esArchivoAudio(String? mime, String? nombre) {
final mimeRecortado = mime?.trim();
final nombreRecortado = nombre?.trim();
if (mimeRecortado == null || mimeRecortado.isEmpty) return false;
if (nombreRecortado == null || nombreRecortado.isEmpty) return false;
return mimeRecortado.toLowerCase().startsWith('audio/');
}
/// Browse-source abstraction for the local-music branch of the Android Auto
/// tree (Design "Interfaces / Contracts"), mirroring [FuenteEmisorasAuto]'s
/// (`navegacion_auto.dart`) cold-start-safe, never-throws contract. Kept as
/// a separate interface from [FuenteEmisorasAuto] — local music is its own
/// browse domain, not a station source.
abstract class FuenteMusicaLocalAuto {
/// Whether a local-music root folder is picked AND its permission is
/// still valid. Never throws — a revoked/never-granted permission
/// degrades to `false` (Spec "Permission revoked or never granted").
Future<bool> hayCarpetaConfigurada();
/// Immediate children of [documentId] (`''` = the tree root itself), one
/// SAF level deep (Design "Lazy per-folder enumeration, never an eager
/// tree dump"). Never throws — any failure degrades to `[]` (Spec
/// "Permission revoked or never granted", "Browse requested before app
/// state is loaded").
Future<List<NodoLocal>> hijos(String documentId);
/// Resolves a leaf [documentId] to its playable `content://` URI, or
/// `null` if it cannot be resolved (stale id, revoked permission). Never
/// throws.
Future<String?> uriContenidoDePista(String documentId);
}
/// Channel-backed [FuenteMusicaLocalAuto] implementation (Design "Hand-rolled
/// SAF channel, not `shared_storage`"): calls the existing
/// `pluriwave/file_actions` `MethodChannel`'s native SAF methods
/// (`MainActivity.kt`, static-review-only) and the picker/persistence side
/// used by the phone settings UI. Every channel call is wrapped in
/// try/catch so a revoked permission, a missing native method (older APK on
/// a mismatched build) or any other native-side failure degrades to an
/// empty/absent result instead of throwing — mirrors
/// `FuenteEmisorasAutoLocal`'s cold-start-safe shape
/// (`navegacion_auto.dart:421-471`).
class FuenteMusicaLocalAutoImpl implements FuenteMusicaLocalAuto {
FuenteMusicaLocalAutoImpl({SharedPreferences? prefs}) : _prefs = prefs;
static const MethodChannel _canal = MethodChannel('pluriwave/file_actions');
final SharedPreferences? _prefs;
/// Injected startup instance (S3-R4 convention, mirrors
/// `ServicioEcualizador`'s DI pattern, `servicio_ecualizador.dart:37,54,57`
/// — `getInstance()` is only a fallback for call sites that don't inject
/// one, e.g. tests or a lazily-constructed settings-only instance).
Future<SharedPreferences> _resolverPrefs() async =>
_prefs ?? SharedPreferences.getInstance();
Future<String?> _uriPersistida() async {
final prefs = await _resolverPrefs();
return prefs.getString(_keyUriCarpetaLocal);
}
/// Persists [treeUri] as the local-music root (Design "Data Flow" —
/// settings write side). Exposed separately from [elegirCarpeta] so a
/// caller that already has a URI (e.g. a future restore/import flow)
/// doesn't need to re-invoke the native picker.
Future<void> guardarCarpeta(String treeUri) async {
final prefs = await _resolverPrefs();
await prefs.setString(_keyUriCarpetaLocal, treeUri);
}
/// The currently persisted root URI, or `null` if none was ever picked.
/// Used by the settings UI to render the "current folder" state.
Future<String?> carpetaActual() => _uriPersistida();
/// Launches the native SAF folder picker (`pickMusicFolder`) and persists
/// the result on success (Spec "User picks a local music root folder").
/// Returns the picked tree URI, or `null` if the user cancelled or the
/// native call failed — never throws.
Future<String?> elegirCarpeta() async {
try {
final uri = await _canal.invokeMethod<String>('pickMusicFolder');
if (uri == null || uri.isEmpty) return null;
await guardarCarpeta(uri);
return uri;
} catch (_) {
return null;
}
}
@override
Future<bool> hayCarpetaConfigurada() async {
try {
final uri = await _uriPersistida();
if (uri == null || uri.isEmpty) return false;
final valido = await _canal.invokeMethod<bool>(
'hasPersistedPermission',
{'treeUri': uri},
);
return valido ?? false;
} catch (_) {
// Cold-start / revoked-permission safety (Spec "Permission revoked or
// never granted"): never throw, degrade to "not configured".
return false;
}
}
@override
Future<List<NodoLocal>> hijos(String documentId) async {
try {
final uri = await _uriPersistida();
if (uri == null || uri.isEmpty) return const [];
final crudos = await _canal.invokeMethod<List<Object?>>(
'listAudioChildren',
{'treeUri': uri, 'parentDocumentId': documentId},
);
if (crudos == null) return const [];
return crudos
.whereType<Map<Object?, Object?>>()
.map(_nodoDesdeMapa)
.whereType<NodoLocal>()
.toList();
} catch (_) {
// Cold-start / revoked-permission safety (Spec "Permission revoked or
// never granted", "Browse requested before app state is loaded").
return const [];
}
}
@override
Future<String?> uriContenidoDePista(String documentId) async {
try {
final uri = await _uriPersistida();
if (uri == null || uri.isEmpty) return null;
return await _canal.invokeMethod<String>('resolvePlayableUri', {
'treeUri': uri,
'documentId': documentId,
});
} catch (_) {
return null;
}
}
/// Maps a raw `listAudioChildren` row to a [NodoLocal], re-validating
/// audio files via [esArchivoAudio] (Design "Interfaces / Contracts" —
/// defense-in-depth on top of the native `audio/*` filter). Returns `null`
/// for a malformed row (missing id/name) or a file whose MIME fails
/// re-validation, so [hijos] can silently drop it instead of surfacing a
/// broken entry.
NodoLocal? _nodoDesdeMapa(Map<Object?, Object?> mapa) {
final documentId = mapa['documentId'] as String?;
final nombre = mapa['nombre'] as String?;
final esDirectorio = mapa['esDirectorio'] as bool? ?? false;
if (documentId == null || documentId.isEmpty) return null;
if (nombre == null) return null;
if (!esDirectorio) {
final mime = mapa['mime'] as String?;
if (!esArchivoAudio(mime, nombre)) return null;
}
return NodoLocal(
documentId: documentId,
nombre: nombre,
esDirectorio: esDirectorio,
);
}
}