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 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> 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 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 _resolverPrefs() async => _prefs ?? SharedPreferences.getInstance(); Future _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 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 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 elegirCarpeta() async { try { final uri = await _canal.invokeMethod('pickMusicFolder'); if (uri == null || uri.isEmpty) return null; await guardarCarpeta(uri); return uri; } catch (_) { return null; } } @override Future hayCarpetaConfigurada() async { try { final uri = await _uriPersistida(); if (uri == null || uri.isEmpty) return false; final valido = await _canal.invokeMethod( '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> hijos(String documentId) async { try { final uri = await _uriPersistida(); if (uri == null || uri.isEmpty) return const []; final crudos = await _canal.invokeMethod>( 'listAudioChildren', {'treeUri': uri, 'parentDocumentId': documentId}, ); if (crudos == null) return const []; return crudos .whereType>() .map(_nodoDesdeMapa) .whereType() .toList(); } catch (_) { // Cold-start / revoked-permission safety (Spec "Permission revoked or // never granted", "Browse requested before app state is loaded"). return const []; } } @override Future uriContenidoDePista(String documentId) async { try { final uri = await _uriPersistida(); if (uri == null || uri.isEmpty) return null; return await _canal.invokeMethod('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 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, ); } }