feat(auto): browse and play local music folders in Android Auto [size:exception]
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.
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user