import 'dart:collection'; 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/'); } /// Pure-Dart, SAF-URI-parsing derivation of a human-readable folder name /// (Design ADR-4) — NO native round-trip. SAF tree URIs are /// `content:///tree/`; `Uri.pathSegments` /// already percent-decodes each segment, so the segment right after `tree` /// is the decoded documentId (e.g. `primary:Music/MyFolder`, /// `1A2B-3C4D:Music`). The trailing readable part of that documentId is /// extracted: everything after the last `/` when present, else everything /// after the last `:`, trimmed. An unparseable [treeUri], a missing/empty /// `tree` segment, or an empty-after-trim result all fall back to /// [nombreGenerico] — this function NEVER returns the raw `content://` URI /// and NEVER returns an empty string. /// /// [nombreGenerico] is the caller-supplied fallback text (Design ADR-4/ADR-5 /// — genuine phone UI, localized via `AppLocalizations.localMusicFolderGenericName` /// at the call site in `pantalla_ajustes.dart`). Taking it as a plain /// `String` parameter — rather than a `BuildContext`/`AppLocalizations` /// dependency — keeps this function pure and unit-testable without a /// widget tree, mirroring `pantalla_reproductor.dart`'s /// `_formatearDuracion(AppLocalizations l10n, ...)` precedent, minus the /// Flutter-generated-class coupling. String nombreCarpetaDesdeUri(String treeUri, {required String nombreGenerico}) { final uri = Uri.tryParse(treeUri); if (uri == null) return nombreGenerico; final segmentos = uri.pathSegments; final indiceTree = segmentos.indexOf('tree'); if (indiceTree == -1 || indiceTree + 1 >= segmentos.length) { return nombreGenerico; } final documentId = segmentos[indiceTree + 1]; if (documentId.isEmpty) return nombreGenerico; final String segmento; final ultimaBarra = documentId.lastIndexOf('/'); if (ultimaBarra >= 0) { segmento = documentId.substring(ultimaBarra + 1); } else { final ultimosDosPuntos = documentId.lastIndexOf(':'); segmento = ultimosDosPuntos >= 0 ? documentId.substring(ultimosDosPuntos + 1) : documentId; } final recortado = segmento.trim(); return recortado.isEmpty ? nombreGenerico : recortado; } /// 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); /// Batched embedded-metadata resolution (Design "Interfaces / Contracts", /// Phase 2) for [documentIds] — one map entry per requested id that was /// resolvable. Never throws: an empty [documentIds], a missing root /// folder, or any channel failure degrades to `{}`. A native row with a /// null/missing field yields a [MetadatosPista] with that field `null`, /// never a crash or a dropped entry. Future> metadatosDe(List documentIds); } /// In-memory, session-scoped LRU cache of resolved [MetadatosPista] (Design /// ADR-2): a flat `LinkedHashMap`, bounded to [_capacidad] entries, /// LRU-by-ACCESS (not just insertion) — [obtener] on a hit re-inserts the /// entry to refresh its recency, so a hot re-visited entry survives even /// under eviction pressure. Deliberately NOT folder-scoped: paging a large /// folder must not evict an earlier page's cached metadata (Design ADR-2's /// rationale — 256 ≈ 5 pages of 50). In-memory only; dies with the process, /// so there is no persistence-staleness concern. class CacheMetadatosSesion { static const _capacidad = 256; final LinkedHashMap _entradas = LinkedHashMap(); /// Returns the cached [MetadatosPista] for [documentId], or `null` on a /// miss. A hit refreshes [documentId]'s recency (moves it to the /// most-recently-used end) so it survives longer under LRU eviction. MetadatosPista? obtener(String documentId) { final valor = _entradas.remove(documentId); if (valor == null) return null; _entradas[documentId] = valor; return valor; } /// Stores [metadatos] under [documentId], refreshing its recency. /// Evicts the least-recently-used entry (the current first key) when /// insertion would exceed [_capacidad]. void guardar(String documentId, MetadatosPista metadatos) { _entradas.remove(documentId); _entradas[documentId] = metadatos; if (_entradas.length > _capacidad) { _entradas.remove(_entradas.keys.first); } } } /// 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; } } @override Future> metadatosDe( List documentIds, ) async { if (documentIds.isEmpty) return const {}; try { final uri = await _uriPersistida(); if (uri == null || uri.isEmpty) return const {}; final crudos = await _canal.invokeMethod>( 'readAudioMetadataBatch', {'treeUri': uri, 'documentIds': documentIds}, ); if (crudos == null) return const {}; final resultado = {}; for (final fila in crudos.whereType>()) { final documentId = fila['documentId'] as String?; if (documentId == null || documentId.isEmpty) continue; resultado[documentId] = MetadatosPista( titulo: fila['titulo'] as String?, artista: fila['artista'] as String?, artUri: fila['artUri'] as String?, bitrate: (fila['bitrate'] as num?)?.toInt(), sampleRate: (fila['sampleRate'] as num?)?.toInt(), ); } return resultado; } catch (_) { // Cold-start / revoked-permission / channel-error safety (Design // "Interfaces / Contracts" — metadatosDe never throws). return const {}; } } /// 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, ); } }