Files
pluriwave/lib/servicios/musica_local_auto.dart
T
FreeTLab 3449e2cb79
Build & Deploy PluriWave / Análisis de código (push) Successful in 27s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 3m23s
fix: corregir ecualizador desincronizado, musica local en Android Auto y bloqueo del paywall
Tres fallos reportados en uso real, con sus causas raiz verificadas en codigo.

1. Ecualizador: el estado no tenia dueño unico

El handler arrancaba con `_ecualizadorActivo = true` a fuego. El valor
persistido solo llegaba por EstadoEcualizador.cargarPersistido(), alcanzable
unicamente desde el arbol de widgets, que un arranque headless de Android Auto
nunca construye. Resultado: el coche reproducia con el EQ forzado a ON mientras
disco e interfaz decian OFF.

Ahora registrarHandler siembra el flag desde disco en todos los motores y
setEcualizadorActivo persiste por su cuenta, asi que un toggle desde el coche o
la notificacion sobrevive sin EstadoEcualizador. _resincronizarConHandler pasa
a ser adopcion pura de interfaz.

Ademas mapearGananciaNativa enviaba 0 dB al punto MEDIO del rango nativo. Con
un getBandLevelRange() asimetrico, un preset plano metia varios dB de boost
real: la causa del "suena muy alto con el boton apagado". Reescrito para
escalar cada lado contra su propio extremo, de modo que 0 dB es siempre 0.

El dispatch de customAction no tenia ningun test. Se extrae decidirToggleEq y
se cubre contra el handler real. El efecto nativo se re-asierta al reactivarse
el reproductor, porque AudioEffect.setEnabled de just_audio es un no-op
mientras la plataforma esta desacoplada.

2. Musica Local no aparecia en el arbol de Android Auto

hayCarpetaConfigurada() consultaba MethodChannel('pluriwave/file_actions'),
registrado solo en MainActivity.configureFlutterEngine. Sin Activity no hay
handler, invokeMethod lanza MissingPluginException y el catch la confundia con
"permiso revocado", omitiendo el nodo. No dependia del entitlement.

La logica SAF sale a packages/pluriwave_file_actions, un paquete plugin local.
El motor headless que crea audio_service ejecuta GeneratedPluginRegistrant en
su constructor, asi que el canal queda registrado en ambos motores. Repuntar el
manifest a una subclase de AudioService no era viable: AudioServicePlugin
enlaza por ComponentName explicito y la app perderia el audio.

EstadoCarpetaLocal de tres valores separa "sin carpeta" de "canal no
disponible"; la raiz decide por la URI persistida y el subarbol muestra un item
explicativo en vez de una carpeta vacia. La invalidacion del arbol cacheado se
dispara al reanudar con el coche ya suscrito; el guardia anterior miraba
View.maybeOf, que bajo runApp siempre existe, por lo que se gastaba en el
arranque headless y no volvia a dispararse.

3. El paywall bloqueaba las compras

restorePurchases() de in_app_purchase_android emite siempre, y con lista vacia
si no hay nada que restaurar. El `if (compras.isEmpty) return;` se la tragaba,
noEncontrada nunca se emitia y la rama que limpia _compraEnCurso estaba muerta
en produccion. Como comprar y restaurar comparten ese flag, un usuario sin
compras que pulsaba restaurar se quedaba sin poder comprar.

Suite completa: 1366 pasan, 2 omitidos. 17 tests nuevos, todos nacidos rojos y
verificados por mutacion. flutter analyze mantiene los 5 avisos preexistentes.
2026-08-31 14:34:49 +02:00

366 lines
16 KiB
Dart

import 'dart:collection';
import 'package:flutter/foundation.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;
}
/// Three-valued answer to «¿hay música local usable?»
/// (fix/android-auto-musica-local).
///
/// Sustituye al `bool` anterior, que colapsaba dos causas MUY distintas en
/// el mismo `false`:
///
/// * [noConfigurada] — no hay URI persistida, o el nativo respondió que el
/// permiso ya no es válido (el usuario nunca eligió carpeta, o la
/// revocó). Es la única respuesta que justifica ocultar el nodo.
/// * [configurada] — hay URI persistida y el nativo confirma el permiso.
/// * [canalNoDisponible] — hay URI persistida pero el canal
/// `pluriwave/file_actions` NO tiene handler nativo, así que no se puede
/// saber nada del permiso. Es lo que ocurre en el motor Flutter headless
/// que `audio_service` levanta cuando Android Auto arranca la app sin
/// Activity: `MainActivity.configureFlutterEngine` (único sitio donde se
/// registra ese canal) nunca corre. NO significa «no hay carpeta».
enum EstadoCarpetaLocal { noConfigurada, configurada, canalNoDisponible }
/// 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 — o si esa pregunta no se puede contestar porque el canal
/// nativo no existe en este motor. Never throws: cualquier fallo degrada
/// a un valor de [EstadoCarpetaLocal], nunca a una excepción (Spec
/// "Permission revoked or never granted").
Future<EstadoCarpetaLocal> estadoCarpeta();
/// 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<EstadoCarpetaLocal> estadoCarpeta() async {
// Its OWN try, deliberately not merged with the channel one below.
//
// Never-throws restoration: the three-valued refactor moved this read
// outside the try, and the only caller (`getChildren`'s root branch)
// awaits it inline — so a prefs failure took the whole browse root down
// and emptied the car, against this method's own interface doc.
//
// Kept SEPARATE because a prefs failure and a channel failure both
// surface as `MissingPluginException`: one shared `on
// MissingPluginException` clause would answer `canalNoDisponible` —
// «hay carpeta pero no puedo comprobar el permiso» — for a store that
// never told us whether a folder exists at all. That would put an
// unreachable «Música Local» node in the car explaining a channel
// problem that is not happening, which is precisely the collapse the
// three-valued [EstadoCarpetaLocal] exists to prevent.
//
// `noConfigurada` is the honest answer here (the app cannot prove a
// folder was ever picked) and is what this path returned before the
// refactor, when the read still sat inside the catch-all below.
final String? uri;
try {
uri = await _uriPersistida();
} catch (e) {
debugPrint('[PluriWave][musica_local] no se pudo leer la URI local: $e');
return EstadoCarpetaLocal.noConfigurada;
}
if (uri == null || uri.isEmpty) return EstadoCarpetaLocal.noConfigurada;
try {
final valido = await _canal.invokeMethod<bool>('hasPersistedPermission', {
'treeUri': uri,
});
return valido == true
? EstadoCarpetaLocal.configurada
: EstadoCarpetaLocal.noConfigurada;
} on MissingPluginException catch (e) {
// El canal no tiene handler en ESTE motor. Antes esto caía en el
// mismo `catch (_)` que un permiso revocado y devolvía `false`, que
// es exactamente por lo que «Música Local» desaparecía del árbol de
// Android Auto cuando el coche arrancaba la app sin Activity.
debugPrint(
'[PluriWave][musica_local] hasPersistedPermission sin handler '
'nativo (motor sin Activity): $e',
);
return EstadoCarpetaLocal.canalNoDisponible;
} catch (e) {
// Cold-start / revoked-permission safety (Spec "Permission revoked or
// never granted"): never throw, degrade to "not configured".
debugPrint('[PluriWave][musica_local] hasPersistedPermission ERROR $e');
return EstadoCarpetaLocal.noConfigurada;
}
}
@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,
);
}
}