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.
228 lines
9.4 KiB
Dart
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,
|
|
);
|
|
}
|
|
}
|