Every diagnostic line in the audio path used `dart:developer`'s `log()`. That function writes to the VM service, which a RELEASE build does not have — so in the only build that ever runs in a car, all eleven of them went nowhere. `debugPrint`/`print` do reach logcat in release; `log()` does not. That includes the two channels built specifically to end the guessing: - `registrarErrorAudioService`, which subscribes to `AudioService.asyncError` so the plugin's swallowed platform exceptions stop vanishing (b0271fa). It moved them from a dropped PublishSubject to a dropped log call. - `_trazarEstadoPublicado`, the published-state trace added in7054a4cto settle why the car's play button never becomes pause. So "no evidence" was never a quiet app. It was an app writing its evidence somewhere release builds discard. Several rounds of hypotheses were argued without data that the app was already producing. All eleven now use `debugPrint` with a `[PluriWave][Tag]` prefix, so one filter catches the audio path and the existing alarm lines together: adb logcat | grep PluriWave No behaviour changes. Tests: 1158, unchanged.
307 lines
13 KiB
Dart
307 lines
13 KiB
Dart
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://<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);
|
|
|
|
/// 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<Map<String, MetadatosPista>> metadatosDe(List<String> 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<String, MetadatosPista> _entradas =
|
|
LinkedHashMap<String, MetadatosPista>();
|
|
|
|
/// 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<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;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<Map<String, MetadatosPista>> metadatosDe(
|
|
List<String> documentIds,
|
|
) async {
|
|
if (documentIds.isEmpty) return const {};
|
|
try {
|
|
final uri = await _uriPersistida();
|
|
if (uri == null || uri.isEmpty) return const {};
|
|
final crudos = await _canal.invokeMethod<List<Object?>>(
|
|
'readAudioMetadataBatch',
|
|
{'treeUri': uri, 'documentIds': documentIds},
|
|
);
|
|
if (crudos == null) return const {};
|
|
final resultado = <String, MetadatosPista>{};
|
|
for (final fila in crudos.whereType<Map<Object?, Object?>>()) {
|
|
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<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,
|
|
);
|
|
}
|
|
}
|