Files
pluriwave/lib/servicios/musica_local_auto.dart
T
FreeTLab 725169cd31 feat(auto): page local-music folders instead of truncating at 50 [size:exception]
Folders over the 50-item cap now show a "Mas..." item that reveals
the next page on tap, instead of silently dropping the rest. Paging
slices the cheap raw list before building any MediaItem, so items
beyond the requested page are never resolved (art, title) -- proven
by a call-count test. Also swaps the raw SAF content:// URI shown in
settings for a parsed, human-readable folder name with a localized
fallback across all 13 locales.

servicio_audio.dart is untouched; this stays entirely within the
local-music tree/dispatch layer.
2026-07-19 22:14:05 +02:00

228 lines
9.4 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/');
}
/// Pure-Dart, SAF-URI-parsing derivation of a human-readable folder name
/// (Design ADR-4) — NO native round-trip. SAF tree URIs are
/// `content://<authority>/tree/<encoded-documentId>`; `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<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,
);
}
}