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