fix: corregir ecualizador desincronizado, musica local en Android Auto y bloqueo del paywall
Build & Deploy PluriWave / Análisis de código (push) Successful in 27s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 3m23s

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.
This commit is contained in:
2026-08-31 14:34:49 +02:00
parent 10bb017f4c
commit 3449e2cb79
34 changed files with 2948 additions and 473 deletions
+20 -8
View File
@@ -98,6 +98,13 @@ class EstadoEcualizador extends ChangeNotifier {
/// `_actualizarControlesEq()`, regardless of who triggered it), compare
/// the handler's current EQ state against our cached copy and adopt it on
/// divergence.
///
/// Since eq-estado-unico this is a DISPLAY concern only. The handler owns
/// the flag and persists it itself, so this subscription no longer closes
/// a persistence gap — it just keeps the phone's toggle showing what the
/// engine is really doing. It also cannot be the fix on its own: it exists
/// only while an [EstadoEcualizador] does, and the headless Android Auto
/// engine that produced the bug report never builds one.
StreamSubscription<EstadoReproduccion>? _suscripcionEstadoAudioEq;
PresetEcualizador get presetActual => _presetActual;
@@ -370,8 +377,16 @@ class EstadoEcualizador extends ChangeNotifier {
/// `setEcualizadorActivo`/`aplicarPreset`): doing so would re-trigger the
/// handler's own `_actualizarControlesEq()` re-push, which would tick
/// [ServicioAudio.estadoStream] again and re-enter this method forever.
/// Only a local field write, [servicio] persistence and [notifyListeners]
/// happen here, so a divergence is resolved in a single pass.
/// Only a local field write and [notifyListeners] happen here, so a
/// divergence is resolved in a single pass.
///
/// It is now a PURE UI ADOPT — it does not persist (eq-estado-unico item
/// B). `PluriWaveAudioHandler` writes its own toggle through the port
/// `registrarHandler` injects, so the value is saved on every engine
/// rather than only on one that happens to have built a widget tree. This
/// method could never have been the owner of that fact: it only runs while
/// an [EstadoEcualizador] exists, and on the headless Android Auto engine
/// behind the bug report none ever does.
///
/// Wrapped in try/catch like every other handler-facing read in this
/// class (e.g. [_sembrarDispositivoActual]): a test double or an
@@ -387,13 +402,10 @@ class EstadoEcualizador extends ChangeNotifier {
if (!activoDiverge && !presetDiverge) return;
if (activoDiverge) {
// Display-only adopt: the handler already persisted this value
// through its own write port before it ever reached us. See the
// doc above.
_activo = activoHandler;
// Closes the persistence gap: `PluriWaveAudioHandler` never
// persists anything itself (it must stay headless-constructible,
// with zero SharedPreferences/Provider access) — [servicio] is the
// only owner of EQ persistence, so a car/notification toggle must
// be saved HERE or it is lost on the next process restart.
await servicio.guardarActivo(activoHandler);
}
if (presetDiverge) {
_presetActual = presetHandler;
+2 -2
View File
@@ -3,7 +3,7 @@ import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../servicios/servicio_audio.dart' show notificarDesbloqueoAuto;
import '../servicios/servicio_audio.dart' show invalidarArbolAuto;
import '../servicios/servicio_compras.dart';
/// Versioned persistence key (Design ADR-1) for the permanent, non-consumable
@@ -181,7 +181,7 @@ class EstadoEntitlement extends ChangeNotifier {
// Orchestrator-resolved open question (design.md): actively
// invalidate the Android Auto browse cache on the free -> premium
// transition, rather than waiting for the head unit's own re-bind.
notificarDesbloqueoAuto();
invalidarArbolAuto();
}
}
+106 -8
View File
@@ -16,6 +16,7 @@ import 'servicios/servicio_audio.dart';
import 'servicios/servicio_audio_session.dart';
import 'servicios/servicio_compras.dart';
import 'servicios/servicio_consentimiento.dart';
import 'servicios/servicio_ecualizador.dart';
import 'servicios/servicio_presets_personalizados.dart';
import 'tema/pluriwave_tokens.dart';
@@ -88,7 +89,7 @@ Future<void> main() async {
//
// Regression this fixes, self-inflicted by the reordering above: the root
// menu decides whether to offer "Música Local" with
// `fuenteLocal != null && await fuenteLocal.hayCarpetaConfigurada()`.
// `fuenteLocal != null && await fuenteLocal.estadoCarpeta() != noConfigurada`.
// Moving ONLY the station source above the awaits meant the car could get
// a root response in the window before this line ran, find a null source,
// and be told there is no local music — and Android Auto caches the browse
@@ -103,7 +104,7 @@ Future<void> main() async {
registrarFuenteMusicaLocal(FuenteMusicaLocalAutoImpl());
// Cosmetic, and deliberately NOT awaited: a display preference must never
// gate `runApp`. `_OrientacionResponsiveApp.didChangeDependencies` applies
// gate `runApp`. `OrientacionResponsiveApp.didChangeDependencies` applies
// it again as soon as a real view exists, which is the only moment it can
// actually take effect anyway.
unawaited(aplicarPoliticaOrientacion());
@@ -151,6 +152,18 @@ Future<void> main() async {
final presetsPersonalizados = ServicioPresetsPersonalizados(prefs: prefs);
registrarFuentePresetsPersonalizados(presetsPersonalizados.listar);
// eq-estado-unico items A/B: the handler's own link to the equalizer's
// persisted on/off flag. `ServicioEcualizador` needs nothing but the
// `prefs` instance resolved just above — no widget tree, no Provider — so
// it is available on EVERY engine, including the headless one Android Auto
// starts. Before this, the persisted value only reached the handler
// through `EstadoEcualizador.cargarPersistido()`, which that engine never
// runs: the handler played with the equalizer forced on while disk and the
// phone UI both said off, and a toggle made in the car was lost on
// restart. Passed as two narrow function ports, mirroring the
// read-function convention used for the preset folder right above.
final ecualizador = ServicioEcualizador(prefs: prefs);
// Silent-error channel (fix/notificacion-media): `AudioService.asyncError`
// had ZERO subscribers app-wide, and a `PublishSubject` with no listeners
// drops what it is given — so every exception `audio_service` catches
@@ -179,7 +192,11 @@ Future<void> main() async {
// radio; headphones unplugged pauses it. Shared by both the on-time and
// degraded/late-completion paths below.
void conectarHandler(PluriWaveAudioHandler handler) {
registrarHandler(handler);
registrarHandler(
handler,
leerEqActivoPersistido: ecualizador.leerActivo,
guardarEqActivoPersistido: ecualizador.guardarActivo,
);
// The handler is the only thing this app ever tears down
// (`onTaskRemoved`), so the asyncError subscription's `cancel` travels
// with it and can never leak — same "register from main.dart" convention
@@ -189,7 +206,7 @@ Future<void> main() async {
unawaited(sesionAudio.configurar());
}
Widget construirApp() => _OrientacionResponsiveApp(
Widget construirApp() => OrientacionResponsiveApp(
child: PluriWaveApp(prefs: prefs, fuenteAuto: fuenteAuto, compras: compras),
);
@@ -268,20 +285,80 @@ Future<void> aplicarPoliticaOrientacion({
}
}
class _OrientacionResponsiveApp extends StatefulWidget {
const _OrientacionResponsiveApp({required this.child});
/// Whether the Android Auto browse tree must be invalidated right now
/// (fix/android-auto-musica-local, item 4 — CORRECTED trigger).
///
/// The trigger used to be `View.maybeOf(context) != null` inside
/// `didChangeDependencies`, latched once, on the premise that «a View means
/// there is an Activity». That premise is FALSE: `runApp` unconditionally
/// wraps the tree in a `View` built from
/// `platformDispatcher.implicitView` and throws a `StateError` when there is
/// none (`flutter/lib/src/widgets/binding.dart`, `wrapWithDefaultView`). So
/// on the headless `audio_service` engine — which demonstrably reaches
/// `runApp`, see [aplicarPoliticaOrientacion] — the View is ALREADY there at
/// the first `didChangeDependencies`. The one-shot latch was spent at the
/// exact moment it could accomplish nothing (`_childrenSubjects` still
/// empty, so `notificarHijosCambiaron` is a silent no-op) and could never
/// fire again, because `didChangeDependencies` does not re-run when an
/// Activity later attaches to that same cached engine.
///
/// Two conditions replace it, both required:
///
/// * [estado] is [AppLifecycleState.resumed] — the only state that genuinely
/// means «an Activity is attached and in the foreground». It reaches Dart
/// exclusively through `SystemChannels.lifecycle` (or
/// `PlatformDispatcher.initialLifecycleState`, which buffers the same
/// messages), and on Android only `LifecycleChannel.appIsResumed()` sends
/// it, driven by the Activity's own `onResume`.
/// `AudioServicePlugin.getFlutterEngine` builds its engine from the
/// APPLICATION context and runs the Dart entrypoint immediately, with no
/// Activity and no `FlutterActivityAndFragmentDelegate`, so nothing sends
/// it on the headless engine.
/// * [hayCocheSuscrito] — a head unit has actually subscribed to at least
/// one browse id (`hayCocheSuscritoAlArbol`). This is what makes the latch
/// worth spending, and it is also the belt to `resumed`'s braces: even if
/// a lifecycle event did somehow arrive during a headless cold start,
/// nothing has subscribed yet, so the latch survives for the moment an
/// Activity really does attach.
///
/// [yaInvalidado] keeps it one-shot: an app foregrounded twenty times must
/// not send twenty `notifyChildrenChanged` storms to the car.
///
/// Pure, so the whole policy is testable without an engine.
@visibleForTesting
bool debeInvalidarArbolAutoAlReanudar({
required AppLifecycleState estado,
required bool hayCocheSuscrito,
required bool yaInvalidado,
}) =>
!yaInvalidado && hayCocheSuscrito && estado == AppLifecycleState.resumed;
/// Root wrapper that keeps the orientation policy applied and owns the
/// Android Auto browse-tree recovery hook.
///
/// Public only so a test can mount it and drive real lifecycle events
/// through [debeInvalidarArbolAutoAlReanudar]'s call site — the previous
/// trigger shipped broken precisely because nothing could reach it.
@visibleForTesting
class OrientacionResponsiveApp extends StatefulWidget {
const OrientacionResponsiveApp({super.key, required this.child});
final Widget child;
@override
State<_OrientacionResponsiveApp> createState() =>
State<OrientacionResponsiveApp> createState() =>
_OrientacionResponsiveAppState();
}
class _OrientacionResponsiveAppState extends State<_OrientacionResponsiveApp>
class _OrientacionResponsiveAppState extends State<OrientacionResponsiveApp>
with WidgetsBindingObserver {
ui.Display? _display;
/// fix/android-auto-musica-local, item 4: la invalidación del árbol del
/// coche se dispara UNA sola vez. Ver
/// [debeInvalidarArbolAutoAlReanudar].
bool _arbolAutoInvalidado = false;
@override
void initState() {
super.initState();
@@ -295,6 +372,27 @@ class _OrientacionResponsiveAppState extends State<_OrientacionResponsiveApp>
unawaited(aplicarPoliticaOrientacion(display: _display));
}
/// `resumed` es lo único que significa de verdad «ya hay Activity
/// adjunta», y con ella el handler nativo de `pluriwave/file_actions` que
/// `MainActivity.configureFlutterEngine` instala. Si el coche había
/// navegado la raíz ANTES (arranque headless), la cacheó sin poder
/// resolver la música local; Android Auto no vuelve a preguntar por su
/// cuenta, así que se lo decimos aquí. Ver
/// [debeInvalidarArbolAutoAlReanudar] para las tres condiciones.
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
if (!debeInvalidarArbolAutoAlReanudar(
estado: state,
hayCocheSuscrito: hayCocheSuscritoAlArbol(),
yaInvalidado: _arbolAutoInvalidado,
)) {
return;
}
_arbolAutoInvalidado = true;
invalidarArbolAuto();
}
@override
void didChangeMetrics() {
unawaited(aplicarPoliticaOrientacion(display: _display));
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../../l10n/gen/app_localizations.dart';
import '../../servicios/musica_local_auto.dart';
import '../../servicios/servicio_audio.dart' show invalidarArbolAuto;
import '../../widgets/pluri_glass_surface.dart';
import '../../widgets/pluri_layout.dart';
import '../../widgets/pluri_push_scaffold.dart';
@@ -54,6 +55,15 @@ class _CuerpoMusicaLocalState extends State<_CuerpoMusicaLocal> {
final uri = await _fuente.elegirCarpeta();
if (uri == null) return; // Cancelled — no snackbar, matches the SAF
// picker's own "nothing changed" affordance.
// fix/android-auto-musica-local, item 4: acaba de aparecer música
// local donde antes no había. Android Auto cachea la raíz y no
// vuelve a preguntar por su cuenta, así que sin esto el coche seguía
// sin ofrecer «Música Local» hasta el siguiente re-bind — que puede
// no llegar en toda la sesión. Fuera del `context.mounted` de abajo:
// el árbol del coche no depende de que esta pantalla siga viva.
invalidarArbolAuto();
if (!context.mounted) return;
setState(() {
_carpetaActual = Future.value(uri);
+11 -5
View File
@@ -5,11 +5,17 @@ import 'package:flutter/material.dart';
import '../tema/pluriwave_tokens.dart';
/// Timeout applied to the `AudioService.init` MediaBrowser handshake (Design
/// "Timeout without re-init"): the vendored `audio_service` plugin's
/// self-bind has no native timeout and an unhandled `onConnectionSuspended`
/// case, so under bind contention (Android Auto cold start) the handshake
/// can hang forever. Top-level const so tests can reference the production
/// value without duplicating it.
/// "Timeout without re-init"): the `audio_service` plugin's self-bind has no
/// native timeout and an unhandled `onConnectionSuspended` case, so under
/// bind contention (Android Auto cold start) the handshake can hang forever.
/// Top-level const so tests can reference the production value without
/// duplicating it.
///
/// This doc called the plugin "vendored". It is not: `pubspec.lock` pins the
/// hosted pub.dev `audio_service` 0.18.18 and `pubspec.yaml` declares no
/// `dependency_overrides`. Anyone reading the sentence above would go looking
/// for a local copy to patch, and there is none — the behaviour described is
/// upstream's, so the workaround has to live here.
const timeoutArranqueAudio = Duration(seconds: 8);
/// Outcome of racing an `AudioService.init` future against
+4 -2
View File
@@ -6,8 +6,10 @@ import '../modelos/pista_local.dart';
/// instance rather than mutating in place, mirroring how
/// `ControladorReconexion` was extracted from `PluriWaveAudioHandler`
/// (`controlador_reconexion.dart`) so this stays fully unit-testable without
/// the handler (which cannot be instantiated in unit tests — see this
/// module's sibling test file's doc comment).
/// the handler. (That last clause used to read "which cannot be instantiated
/// in unit tests"; it can — see `construirControlesTransporte`'s doc in
/// `servicio_audio.dart`. Keeping the queue logic out of the handler is
/// still worth it, but for design reasons, not for that one.)
class ColaLocal {
const ColaLocal({required this.pistas, this.indice = 0});
+68 -9
View File
@@ -1,5 +1,6 @@
import 'dart:collection';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -71,6 +72,24 @@ String nombreCarpetaDesdeUri(String treeUri, {required String nombreGenerico}) {
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
@@ -78,9 +97,11 @@ String nombreCarpetaDesdeUri(String treeUri, {required String nombreGenerico}) {
/// 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();
/// 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
@@ -197,18 +218,56 @@ class FuenteMusicaLocalAutoImpl implements FuenteMusicaLocalAuto {
}
@override
Future<bool> hayCarpetaConfigurada() async {
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 uri = await _uriPersistida();
if (uri == null || uri.isEmpty) return false;
final valido = await _canal.invokeMethod<bool>('hasPersistedPermission', {
'treeUri': uri,
});
return valido ?? false;
} catch (_) {
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".
return false;
debugPrint('[PluriWave][musica_local] hasPersistedPermission ERROR $e');
return EstadoCarpetaLocal.noConfigurada;
}
}
+38 -3
View File
@@ -229,6 +229,17 @@ class ConstructorArbolAuto {
/// hidden.
static const idEcualizador = 'ecualizador';
/// Non-playable "no puedo leer la carpeta desde aquí" item
/// (fix/android-auto-musica-local). La raíz ya no oculta [idMusicaLocal]
/// cuando el canal nativo `pluriwave/file_actions` no está disponible en
/// este motor, así que abrir la carpeta tenía que dejar de mostrar una
/// lista vacía: vacío se lee como «no tengo música», que es justo la
/// conclusión equivocada. Este item dice qué pasa de verdad.
///
/// Colisión imposible con los prefijos `carpeta_local:` / `pista:` /
/// `emisora:` / `grupo:` — no lleva ninguno de ellos.
static const idLocalNoLista = 'musica_local_no_disponible';
static const _idsCarpetas = {idFavoritos, idTodas, idMisEmisoras};
static const _maxItemsPorCarpeta = 50;
@@ -331,8 +342,10 @@ class ConstructorArbolAuto {
///
/// `Música Local` is OMITTED entirely (not just empty) unless
/// [incluirMusicaLocal] is `true` (Design "Local root hidden until a folder
/// is configured") — the caller passes `fuente.hayCarpetaConfigurada()`,
/// keeping this builder itself synchronous and side-effect free.
/// is configured") — the caller lo deriva de
/// `fuente.estadoCarpeta() != EstadoCarpetaLocal.noConfigurada`
/// (fix/android-auto-musica-local: un canal nativo ausente ya NO oculta el
/// nodo), keeping this builder itself synchronous and side-effect free.
///
/// [premium] (iap-freemium-unlock, Design ADR-4): the ROOT keeps the exact
/// same visible folder labels for every tier — "keeps the same visible
@@ -370,6 +383,17 @@ class ConstructorArbolAuto {
extras: _contentStyleLista,
);
/// El item de [idLocalNoLista]. Etiqueta en castellano hardcodeado, como
/// TODAS las etiquetas del árbol del coche en este archivo (ver
/// [itemPremiumBloqueado]): convención establecida, nunca
/// `AppLocalizations`. No reproducible — seleccionarlo es un no-op.
MediaItem itemLocalNoDisponible() => MediaItem(
id: idLocalNoLista,
title: 'Abre PluriWave en el móvil para leer tu música',
playable: false,
extras: _contentStyleLista,
);
MediaItem _carpeta(String id, String titulo) => MediaItem(
id: id,
title: titulo,
@@ -1584,13 +1608,24 @@ Future<List<MediaItem>?> hijosMusicaLocal(
if (fuente == null) return const [];
try {
final nodos = await fuente.hijos(documentId);
return await constructor.itemsLocales(
final items = await constructor.itemsLocales(
nodos,
documentIdPadre: documentId,
pagina: pagina,
metadatosDe: (ids) => _metadatosDeConCache(ids, fuente: fuente),
fuente: fuente,
);
// fix/android-auto-musica-local: si no salió NADA, el motivo importa.
// Con el canal nativo caído (motor sin Activity) `hijos` degrada a `[]`
// igual que una carpeta realmente vacía, y una carpeta vacía en el
// coche se lee como «no tengo música». El estado se consulta SOLO en
// ese caso vacío, así que la ruta normal no paga ningún round trip
// extra.
if (items.isEmpty &&
await fuente.estadoCarpeta() == EstadoCarpetaLocal.canalNoDisponible) {
return [constructor.itemLocalNoDisponible()];
}
return items;
} catch (_) {
return const [];
}
+561 -129
View File
@@ -36,13 +36,93 @@ enum EstadoReproduccion {
// ─────────────────────────────────────────────────────────────────────────────
PluriWaveAudioHandler? _handlerGlobal;
void registrarHandler(PluriWaveAudioHandler handler) {
/// Read port for the persisted equalizer on/off flag (eq-estado-unico item A).
/// In production `main.dart` binds it to `ServicioEcualizador.leerActivo`,
/// which needs nothing but the `SharedPreferences` instance already resolved
/// before `AudioService.init`. `null` for any caller that has no disk (widget
/// tests, fakes) — seeding is then skipped entirely.
typedef LeerEqActivoPersistido = Future<bool?> Function();
/// Write port for the same flag (eq-estado-unico item B). Bound to
/// `ServicioEcualizador.guardarActivo`.
typedef GuardarEqActivoPersistido = Future<void> Function(bool activo);
/// Last value read from disk for the equalizer on/off flag, or `null` while
/// nothing has been read yet.
///
/// This exists purely to close the construction window: `AudioService.init`
/// builds the handler through its `builder` callback, and only AFTER that
/// future resolves does `main.dart` reach [registrarHandler]. A car tap
/// landing inside that window would otherwise hit a handler whose flag had
/// never seen disk. Once one engine has read the value, any handler built
/// afterwards starts from it instead of from a hardcoded default.
bool? _eqActivoPersistido;
/// The equalizer's initial on/off state for a freshly started engine.
///
/// Pure seam (eq-estado-unico item A): [PluriWaveAudioHandler] used to
/// hardcode `_ecualizadorActivo = true`, so a process started HEADLESSLY by
/// Android Auto — no Activity, no Provider tree, so no
/// `EstadoEcualizador.cargarPersistido()` — played with the equalizer forced
/// on while disk and the phone UI both said off. That is the reported «suena
/// muy alto con el boton desactivado».
///
/// `null` means "nothing was ever persisted" (first install, or a wiped
/// preference) and keeps the historical default of ON. It must NOT be
/// confused with "off": a user who has never touched the toggle expects the
/// equalizer on, and the app has always behaved that way.
bool estadoEqInicial({required bool? persistido}) => persistido ?? true;
/// Reads the persisted equalizer flag through [leer] exactly once and seeds
/// [handler] with it, without ever writing back.
///
/// Never throws: an unreadable preference store leaves the handler on
/// [estadoEqInicial]'s default rather than taking down the audio bootstrap.
Future<void> _sembrarEcualizadorDesdeDisco(
PluriWaveAudioHandler handler,
LeerEqActivoPersistido leer,
) async {
bool? persistido;
try {
persistido = await leer();
} catch (e) {
debugPrint(
'[PluriWave][ServicioAudio] no se pudo leer el estado EQ persistido: $e',
);
persistido = null;
}
_eqActivoPersistido = persistido;
await handler.sembrarEcualizadorActivo(
estadoEqInicial(persistido: persistido),
);
}
/// Wires the freshly built handler into the module-level seams.
///
/// [leerEqActivoPersistido] and [guardarEqActivoPersistido] give the handler
/// its own, UI-independent link to the equalizer's persisted on/off flag
/// (eq-estado-unico items A and B). Before them the flag reached the handler
/// only through `EstadoEcualizador.cargarPersistido()`, i.e. only on an
/// engine that had actually built the widget tree — which a headless Android
/// Auto bind never does. Both are optional so every existing caller (widget
/// tests, fakes) keeps compiling and behaving exactly as before.
void registrarHandler(
PluriWaveAudioHandler handler, {
LeerEqActivoPersistido? leerEqActivoPersistido,
GuardarEqActivoPersistido? guardarEqActivoPersistido,
}) {
_handlerGlobal = handler;
// iap-freemium-unlock (design.md Open Questions, orchestrator-resolved):
// on the free -> premium transition, actively invalidate every root-level
// browse id a head unit may have cached while locked, rather than waiting
// for its own re-bind — see [registrarNotificacionDesbloqueoAuto]'s doc.
registrarNotificacionDesbloqueoAuto(() {
// Registered BEFORE the seeding below is awaited so that a toggle arriving
// during the disk read is still persisted.
handler.registrarPersistenciaEq(guardarEqActivoPersistido);
if (leerEqActivoPersistido != null) {
unawaited(_sembrarEcualizadorDesdeDisco(handler, leerEqActivoPersistido));
}
// iap-freemium-unlock (design.md Open Questions, orchestrator-resolved),
// generalizado en fix/android-auto-musica-local item 4: invalida
// activamente todo id de nivel raíz que un head unit pueda tener cacheado
// en vez de esperar a su propio re-bind — ver [registrarInvalidacionArbolAuto].
registrarInvalidacionArbolAuto(() {
handler.notificarHijosCambiaron(AudioService.browsableRootId);
handler.notificarHijosCambiaron(ConstructorArbolAuto.idFavoritos);
handler.notificarHijosCambiaron(ConstructorArbolAuto.idTodas);
@@ -153,29 +233,56 @@ void registrarLimpiezaArranque(Future<void> Function() limpieza) {
_limpiezaArranqueGlobal = limpieza;
}
/// Free -> premium Android Auto cache-invalidation hook (design.md Open
/// Questions, orchestrator-resolved): registered from [registrarHandler] so
/// `estado_entitlement.dart` can trigger it WITHOUT ever touching
/// `PluriWaveAudioHandler` directly (that type cannot be constructed in a
/// unit test — see [PluriWaveAudioHandler]'s own doc). `null` until a
/// handler registers (headless cold bind, or a widget-only test that never
/// wires audio) — [notificarDesbloqueoAuto] tolerates that silently.
void Function()? _alDesbloquearAutoGlobal;
/// Android Auto browse-cache invalidation hook (design.md Open Questions,
/// orchestrator-resolved): registered from [registrarHandler] so callers
/// can trigger it WITHOUT ever touching `PluriWaveAudioHandler` directly (a
/// layering choice — the entitlement layer has no business knowing the
/// handler type; it is not, as this doc used to claim, because the handler
/// cannot be constructed in a unit test, which is false — see
/// [construirControlesTransporte]). `null` until a handler registers
/// (headless cold bind, or a widget-only test that never wires audio) —
/// [invalidarArbolAuto] tolerates that silently.
///
/// GENERALIZADO (fix/android-auto-musica-local, item 4): nació atado a la
/// transición free -> premium, y ese nombre escondía para qué sirve de
/// verdad. Android Auto CACHEA la raíz, así que hay que invalidarla cada
/// vez que el árbol pasa a poder mostrar algo que antes no podía. Hoy lo
/// disparan tres sitios: la compra premium (`estado_entitlement.dart`), la
/// primera vez que existe una View de verdad — es decir, cuando por fin hay
/// Activity y con ella el handler nativo de `pluriwave/file_actions`
/// (`main.dart`) — y la elección de carpeta de música local
/// (`pantalla_ajustes_musica_local.dart`).
void Function()? _invalidarArbolAutoGlobal;
/// Registers the hook [notificarDesbloqueoAuto] invokes. Exposed at module
/// Registers the hook [invalidarArbolAuto] invokes. Exposed at module
/// level (like every other `registrar*` seam in this file) purely so tests
/// can inject a fake hook and assert it fires, without instantiating a real
/// [PluriWaveAudioHandler].
void registrarNotificacionDesbloqueoAuto(void Function() alDesbloquear) {
_alDesbloquearAutoGlobal = alDesbloquear;
void registrarInvalidacionArbolAuto(void Function() alInvalidar) {
_invalidarArbolAutoGlobal = alInvalidar;
}
/// Fires the registered free -> premium Android Auto invalidation hook, if
/// Fires the registered Android Auto browse-cache invalidation hook, if
/// any. A no-op before a handler ever registers — never throws.
void notificarDesbloqueoAuto() {
_alDesbloquearAutoGlobal?.call();
void invalidarArbolAuto() {
_invalidarArbolAutoGlobal?.call();
}
/// Whether a head unit has actually SUBSCRIBED to at least one browse id on
/// the live handler (fix/android-auto-musica-local, item 4 — corrected).
///
/// This is the precondition that makes [invalidarArbolAuto] worth firing at
/// all: [PluriWaveAudioHandler.notificarHijosCambiaron] is
/// `_childrenSubjects[id]?.add(...)`, so invalidating before the car has
/// subscribed to ANYTHING is provably a silent no-op — which is exactly how
/// the old `View.maybeOf(context) != null` trigger managed to burn its
/// one-shot latch during the headless cold start and never fire again.
///
/// Module-level, like every other seam in this file, so `main.dart` can ask
/// the question without importing the handler type, and `false` when no
/// handler has registered yet (headless cold bind, widget-only tests).
bool hayCocheSuscritoAlArbol() => _handlerGlobal?.hayCocheSuscrito ?? false;
/// Pure Android Auto play-path gate decision (iap-freemium-unlock, Design
/// ADR-4): whether a station-switch dispatch (`playFromMediaId`,
/// `playFromSearch`, `skipToNext`, `skipToPrevious`) must no-op for
@@ -291,6 +398,95 @@ AudioProcessingState mapearEstadoProceso(
/// media id).
const accionEqToggle = 'eq_toggle';
/// What an [accionEqToggle] tap resolves to (eq-estado-unico item C).
class DecisionToggleEq {
const DecisionToggleEq({
required this.nuevoActivo,
required this.requiereLlamadaNativa,
});
/// The on/off value the handler must end up holding.
final bool nuevoActivo;
/// Whether the native `AndroidEqualizer` effect must also be told. `false`
/// on a device with no usable Equalizer effect: the flag still flips (so
/// the car button never looks inert and the label still updates) but
/// nothing is pushed to the platform.
final bool requiereLlamadaNativa;
@override
bool operator ==(Object other) =>
other is DecisionToggleEq &&
other.nuevoActivo == nuevoActivo &&
other.requiereLlamadaNativa == requiereLlamadaNativa;
@override
int get hashCode => Object.hash(nuevoActivo, requiereLlamadaNativa);
@override
String toString() =>
'DecisionToggleEq(nuevoActivo: $nuevoActivo, '
'requiereLlamadaNativa: $requiereLlamadaNativa)';
}
/// The equalizer toggle decision, extracted out of `customAction` and
/// `setEcualizadorActivo` so it can be tested on its own (eq-estado-unico
/// item C — this dispatch had ZERO tests: `rg "customAction\(" test/`
/// returned nothing).
///
/// Reported: «pulsando sobre el boton de ecualizar en Android Auto tampoco
/// activaba ni desactivaba». Note what this function deliberately does NOT
/// do: gate the flip on [eqDisponible]. The flag always flips, because the
/// notification/car label is built from it — a tap that changed nothing at
/// all is exactly the "the button does nothing" symptom.
DecisionToggleEq decidirToggleEq({
required bool activoActual,
required bool eqDisponible,
}) => DecisionToggleEq(
nuevoActivo: !activoActual,
requiereLlamadaNativa: eqDisponible,
);
/// Translates a gain on the app's fixed ±12 dB slider scale to the range the
/// device's native equalizer actually reports
/// (`AndroidEqualizerParameters.min/maxDecibels`, itself derived from
/// `Equalizer.getBandLevelRange()`).
///
/// Top-level and pure so the mapping is testable without a device.
///
/// THE DEFECT THIS REPLACES, and the likely source of the reported «suena muy
/// alto»: the previous implementation normalised across the whole range and
/// interpolated linearly,
///
/// minDecibels + ((db + 12) / 24) * (maxDecibels - minDecibels)
///
/// which puts 0 dB at the MIDPOINT of the native range. That is only 0 when
/// the range is symmetric, and Android guarantees no such thing — the
/// Equalizer contract only promises a min/max pair. On a device reporting,
/// say, [-12, +19] dB, every band of a FLAT preset was pushed to +3.5 dB of
/// real boost: audibly louder, with the on/off button still reading "off"
/// and nothing in the UI to explain it.
///
/// The contract here instead: 0 dB is always exactly 0, and each side of the
/// scale is stretched independently against its own end of the native range,
/// so a cut can never become a boost. A range with no headroom on one side
/// (or none at all) collapses that side to 0 rather than inverting it.
double mapearGananciaNativa(
double db, {
required double minDecibels,
required double maxDecibels,
}) {
final limitado = db.clamp(-12.0, 12.0);
if (limitado == 0) return 0;
if (limitado > 0) {
// Only genuine headroom above unity counts as boost.
final techo = maxDecibels > 0 ? maxDecibels : 0.0;
return (limitado / 12.0) * techo;
}
final suelo = minDecibels < 0 ? minDecibels : 0.0;
return (limitado.abs() / 12.0) * suelo;
}
/// Advances to the NEXT factory preset after [actual] in [presets] order
/// (Design "EQ custom actions — cycling presets", item 4): wraps around
/// after the last one. When [actual] is not found in [presets] (e.g. a
@@ -381,9 +577,18 @@ List<MediaControl> controlesEcualizadorPersonalizados({
/// its shape. `servicio_audio_controles_notificacion_test.dart` used to
/// re-declare the list inline, which meant it stayed green while asserting a
/// shape `lib/` no longer produced — a guard that cannot see the thing it
/// guards. `PluriWaveAudioHandler` itself cannot be instantiated in a unit
/// test (a real `just_audio.AudioPlayer` needs platform MethodChannels), so
/// pulling the pure part out is the only way to test the real thing.
/// guards.
///
/// This doc used to add that `PluriWaveAudioHandler` "cannot be instantiated
/// in a unit test (a real `just_audio.AudioPlayer` needs platform
/// MethodChannels)". That is NOT true with just_audio 0.9.46:
/// `AudioPlayer`'s constructor resolves its platform lazily and only becomes
/// `_active` on a `setUrl`, so the handler constructs fine under
/// `flutter test` and `servicio_audio_eq_estado_unico_test.dart` drives its
/// real `customAction` dispatch. Only calls that reach the native effect stay
/// out of reach (they sit behind `_eqDisponible`, `false` off-device).
/// Extracting the pure part is still worth it — it is cheaper and states the
/// contract explicitly — but it is no longer the ONLY way.
///
/// ORDER MATTERS, and only for the car.
///
@@ -582,11 +787,18 @@ class ServicioAudio {
bool get ecualizadorDisponible => _handler.ecualizadorDisponible;
PresetEcualizador get presetActual => _handler.presetActual;
/// Forwards the handler's own on/off flag (eq-sync-superficies): a
/// car/notification toggle (`accionEqToggle`) mutates
/// `PluriWaveAudioHandler._ecualizadorActivo` directly, bypassing
/// [setEcualizadorActivo] entirely. [EstadoEcualizador] polls this getter
/// on every [estadoStream] tick to detect and resync that divergence.
/// Forwards the handler's own on/off flag, which since eq-estado-unico is
/// the flag's SINGLE in-memory owner: `EstadoEcualizador._activo` is a
/// display mirror of this getter and `ServicioEcualizador` is its durable
/// copy.
///
/// Corrects a stale claim that stood here: a car/notification toggle does
/// NOT bypass [setEcualizadorActivo]. `PluriWaveAudioHandler.customAction`
/// resolves `accionEqToggle` through `decidirToggleEq` and then calls
/// `setEcualizadorActivo` — the same entry point the phone settings screen
/// uses — so every surface shares one write path, and that path is what
/// persists the value. [EstadoEcualizador] still polls this getter on every
/// [estadoStream] tick, but only to keep its own display in sync.
bool get ecualizadorActivo => _handler.ecualizadorActivo;
Future<void> aplicarPreset(PresetEcualizador preset) =>
@@ -702,13 +914,24 @@ class PluriWaveAudioHandler extends BaseAudioHandler
/// `BehaviorSubject` per id, created lazily on first subscription;
/// [notificarHijosCambiaron] pushes a fresh (empty, content-agnostic)
/// value to trigger the platform notification for that id.
///
/// SIN semilla (fix/android-auto-musica-local, item 5). Antes se creaba
/// con `.seeded(<String, dynamic>{})`, y un `BehaviorSubject` reenvía su
/// valor actual a cada nuevo suscriptor: el listener interno de
/// `audio_service` se suscribe la primera vez que el head unit navega un
/// id, recibía esa semilla al instante y la reenviaba como
/// `notifyChildrenChanged` — o sea, el primer browse de CADA id disparaba
/// un `getChildren` extra que nadie pidió. En la raíz eso era un segundo
/// round trip de permisos por `pluriwave/file_actions`, justo en la ruta
/// que ya estaba fallando en el motor sin Activity. Sin semilla no hay
/// nada que reenviar y la invalidación explícita sigue igual.
final _childrenSubjects = <String, BehaviorSubject<Map<String, dynamic>>>{};
@override
ValueStream<Map<String, dynamic>> subscribeToChildren(String parentMediaId) =>
_childrenSubjects.putIfAbsent(
parentMediaId,
() => BehaviorSubject<Map<String, dynamic>>.seeded(<String, dynamic>{}),
BehaviorSubject<Map<String, dynamic>>.new,
);
/// Invalidates a head unit's cached browse listing for [parentMediaId]
@@ -719,6 +942,12 @@ class PluriWaveAudioHandler extends BaseAudioHandler
_childrenSubjects[parentMediaId]?.add(<String, dynamic>{});
}
/// True once a head unit has subscribed to at least one browse id, i.e.
/// once [notificarHijosCambiaron] can actually reach the car. Read through
/// the module-level [hayCocheSuscritoAlArbol]; see its doc for why the
/// browse-tree invalidation is gated on it.
bool get hayCocheSuscrito => _childrenSubjects.isNotEmpty;
/// True while the handler is inside the reconnect window. [ServicioAudio]
/// maps it to [EstadoReproduccion.reconectando] so the UI shows a loading
/// indicator instead of an error during retries (S7-R3).
@@ -728,9 +957,42 @@ class PluriWaveAudioHandler extends BaseAudioHandler
AndroidEqualizer? get ecualizador => _eq;
bool _eqDisponible = false;
bool get ecualizadorDisponible => _eqDisponible;
bool _ecualizadorActivo = true;
/// The equalizer's on/off state — and, since eq-estado-unico, its SINGLE
/// in-memory owner. `EstadoEcualizador._activo` is now a pure display
/// mirror of this field, and `ServicioEcualizador` is its durable copy.
///
/// It used to be an unconditional `true`, which is exactly why a headless
/// Android Auto engine played with the equalizer on while both the phone
/// UI and disk said off. It now starts from whatever the last disk read
/// produced ([_eqActivoPersistido]); [registrarHandler] then seeds it
/// again from the read port, which is the authoritative path.
bool _ecualizadorActivo = estadoEqInicial(persistido: _eqActivoPersistido);
bool get ecualizadorActivo => _ecualizadorActivo;
/// Write port for [_ecualizadorActivo] (eq-estado-unico item B). Injected
/// by [registrarHandler] so a car/notification toggle is persisted even
/// when no `EstadoEcualizador` has ever been built — which is precisely
/// the headless-bind case where the divergence used to be created.
GuardarEqActivoPersistido? _persistirEqActivo;
/// See [_persistirEqActivo]. Accepts `null` to clear the port (the default
/// for every caller that has no disk).
void registrarPersistenciaEq(GuardarEqActivoPersistido? guardar) {
_persistirEqActivo = guardar;
}
/// The player's live position, used to keep `updatePosition` honest on
/// every `playbackState` push. Exposed so tests can assert the re-push
/// without reaching into the private player.
Duration get posicionActual => _player.position;
/// True while the platform player is attached, i.e. while `just_audio`
/// actually forwards `AudioEffect.setEnabled` to the device
/// (`just_audio.dart:3842-3848` gates it on `_player._active`). Tracked so
/// [debeReasertarEcualizadorNativo] can spot the idle -> active edge.
bool _reproductorActivo = false;
PresetEcualizador _presetActual = PresetEcualizador.flat;
PresetEcualizador get presetActual => _presetActual;
int? get androidAudioSessionId => _androidAudioSessionId;
@@ -758,94 +1020,123 @@ class PluriWaveAudioHandler extends BaseAudioHandler
);
}
void _conectarStreamsPlayer() {
_estadoPlayerSub = _player.playerStateStream.listen((state) {
final playing = state.playing;
final proc = state.processingState;
// First line of the listener (Design ADR-3, Phase 3 task 3.3):
// double-gated on `completed` + an active local queue, so this is a
// no-op for radio (which never emits `completed`) and for
// single-track local playback (which never sets `_colaLocal`).
_manejarFinPista(proc);
if (playing && proc == ProcessingState.ready) {
// Successful (re)connection: reset the backoff so the next stall
// starts over, and leave the reconnect window (S7-R7).
_reconexion.restablecer();
_reconectando = false;
// Local queue (Design ADR-3): the next queued track reached a
// stable playing state — clear the re-entry latch so a LATER
// completion can advance again. A no-op for radio, which never
// sets `_avanzandoCola`.
_avanzandoCola = false;
}
// Local queue transport (Design "Transport wiring"): skip controls
// are only offered while a queue is active — when `_colaLocal` is
// `null` this list/set/index is byte-identical to the pre-change
// radio behavior (regression guard).
final colaActiva = _colaLocal != null;
playbackState.add(
playbackState.value.copyWith(
controls: _controlesTransporte(
colaActiva: colaActiva,
playing: playing,
),
// Android for Cars, "Enable playback control": «Android Auto and
// AAOS display playback controls based on the actions that are
// enabled in the PlaybackStateCompat object. By default, your app
// must support the following actions: ACTION_PLAY, ACTION_PAUSE,
// ACTION_STOP, ACTION_PLAY_FROM_MEDIA_ID, ACTION_PLAY_FROM_SEARCH.»
//
// This set had carried only `seek` + `stop` since the very first
// commit, so the required transport actions were never advertised.
// The car got away with it for a long time — but Android Auto is a
// separate app that updates itself, so a tolerance it used to have
// can disappear without a single line changing on our side. That
// matches the report exactly: "it used to work, and in the latest
// versions it doesn't", with no audio commit in between that could
// explain it.
//
// The phone notification never depended on any of this: it builds
// its play/pause button from `controls`, which is why the symptom
// is car-only.
systemActions: {
MediaAction.play,
MediaAction.pause,
MediaAction.playPause,
MediaAction.stop,
MediaAction.playFromMediaId,
MediaAction.playFromSearch,
MediaAction.seek,
// Previous/next are advertised ALWAYS now, not only for a local
// queue. Android Auto reserves those two slots and only hands the
// space to custom actions when the app declares no support, so
// this is what puts prev/next on the car's transport row -- and
// `skipToNext`/`skipToPrevious` fall back to station-to-station
// skipping when there is no queue, so neither button is inert.
MediaAction.skipToPrevious,
MediaAction.skipToNext,
},
androidCompactActionIndices: [colaActiva ? 1 : 0],
processingState: mapearEstadoProceso(
proc,
cambiandoFuente: _cambiandoFuente,
),
/// The `playerStateStream` listener's whole body, as a named method.
///
/// Extracted verbatim so a test can drive a real player-state transition
/// through the REAL handler. It used to be an anonymous closure, which is
/// why the equalizer's idle -> active re-assert below shipped with
/// producer-only coverage: [debeReasertarEcualizadorNativo] had five tests
/// and not one of them could reach this wiring, so deleting the re-assert
/// block left the suite green. The only thing left outside a test's reach
/// is the one-line `.listen(manejarEstadoPlayer)` subscription in
/// [_conectarStreamsPlayer].
@visibleForTesting
void manejarEstadoPlayer(PlayerState state) {
final playing = state.playing;
final proc = state.processingState;
// First line of the listener (Design ADR-3, Phase 3 task 3.3):
// double-gated on `completed` + an active local queue, so this is a
// no-op for radio (which never emits `completed`) and for
// single-track local playback (which never sets `_colaLocal`).
_manejarFinPista(proc);
// eq-estado-unico item D: `AudioEffect.setEnabled` is a no-op while
// the platform player is detached, so any toggle made while stopped
// never landed natively. Re-assert the value we own on the idle ->
// active edge. See [debeReasertarEcualizadorNativo].
if (debeReasertarEcualizadorNativo(
estado: proc,
reproductorActivoAntes: _reproductorActivo,
eqDisponible: _eqDisponible,
)) {
unawaited(_reasertarEcualizadorNativo());
}
// Turns a stream of many events into a single idle -> active EDGE: the
// re-assert above fires once per activation, not on every event.
_reproductorActivo = proc != ProcessingState.idle;
if (playing && proc == ProcessingState.ready) {
// Successful (re)connection: reset the backoff so the next stall
// starts over, and leave the reconnect window (S7-R7).
_reconexion.restablecer();
_reconectando = false;
// Local queue (Design ADR-3): the next queued track reached a
// stable playing state — clear the re-entry latch so a LATER
// completion can advance again. A no-op for radio, which never
// sets `_avanzandoCola`.
_avanzandoCola = false;
}
// Local queue transport (Design "Transport wiring"): skip controls
// are only offered while a queue is active — when `_colaLocal` is
// `null` this list/set/index is byte-identical to the pre-change
// radio behavior (regression guard).
final colaActiva = _colaLocal != null;
playbackState.add(
playbackState.value.copyWith(
controls: _controlesTransporte(
colaActiva: colaActiva,
playing: playing,
// Reported: in Android Auto the progress bar and the time labels of
// a local track never move. `updatePosition` was NEVER set anywhere
// in this file, so it stayed at its `Duration.zero` default while
// `copyWith` refreshed `updateTime` to now on every push
// (audio_service.dart:411-413, :256). A client extrapolates
// `updatePosition + (now - updateTime) * speed`, so it was told
// "position 0, as of right now" over and over — a bar pinned at the
// start. The phone UI never noticed because it reads
// `_player.positionStream` directly.
updatePosition: _player.position,
bufferedPosition: _player.bufferedPosition,
speed: _player.speed,
),
);
_trazarEstadoPublicado();
});
// Android for Cars, "Enable playback control": «Android Auto and
// AAOS display playback controls based on the actions that are
// enabled in the PlaybackStateCompat object. By default, your app
// must support the following actions: ACTION_PLAY, ACTION_PAUSE,
// ACTION_STOP, ACTION_PLAY_FROM_MEDIA_ID, ACTION_PLAY_FROM_SEARCH.»
//
// This set had carried only `seek` + `stop` since the very first
// commit, so the required transport actions were never advertised.
// The car got away with it for a long time — but Android Auto is a
// separate app that updates itself, so a tolerance it used to have
// can disappear without a single line changing on our side. That
// matches the report exactly: "it used to work, and in the latest
// versions it doesn't", with no audio commit in between that could
// explain it.
//
// The phone notification never depended on any of this: it builds
// its play/pause button from `controls`, which is why the symptom
// is car-only.
systemActions: {
MediaAction.play,
MediaAction.pause,
MediaAction.playPause,
MediaAction.stop,
MediaAction.playFromMediaId,
MediaAction.playFromSearch,
MediaAction.seek,
// Previous/next are advertised ALWAYS now, not only for a local
// queue. Android Auto reserves those two slots and only hands the
// space to custom actions when the app declares no support, so
// this is what puts prev/next on the car's transport row -- and
// `skipToNext`/`skipToPrevious` fall back to station-to-station
// skipping when there is no queue, so neither button is inert.
MediaAction.skipToPrevious,
MediaAction.skipToNext,
},
androidCompactActionIndices: [colaActiva ? 1 : 0],
processingState: mapearEstadoProceso(
proc,
cambiandoFuente: _cambiandoFuente,
),
playing: playing,
// Reported: in Android Auto the progress bar and the time labels of
// a local track never move. `updatePosition` was NEVER set anywhere
// in this file, so it stayed at its `Duration.zero` default while
// `copyWith` refreshed `updateTime` to now on every push
// (audio_service.dart:411-413, :256). A client extrapolates
// `updatePosition + (now - updateTime) * speed`, so it was told
// "position 0, as of right now" over and over — a bar pinned at the
// start. The phone UI never noticed because it reads
// `_player.positionStream` directly.
updatePosition: _player.position,
bufferedPosition: _player.bufferedPosition,
speed: _player.speed,
),
);
_trazarEstadoPublicado();
}
void _conectarStreamsPlayer() {
_estadoPlayerSub = _player.playerStateStream.listen(
manejarEstadoPlayer,
);
_bufferedSub = _player.bufferedPositionStream.listen((pos) {
playbackState.add(
@@ -964,10 +1255,31 @@ class PluriWaveAudioHandler extends BaseAudioHandler
colaActiva: _colaLocal != null,
playing: playbackState.value.playing,
),
// Must ride along, exactly as in the two sibling emissions in
// `_conectarStreamsPlayer`: `copyWith` stamps a fresh `updateTime`
// but keeps the OLD `updatePosition`, so a push without it tells the
// client "you are at <stale position>, as of right now". Every
// equalizer tap therefore snapped the car's progress bar backwards
// to wherever it stood at the last real player event.
updatePosition: _player.position,
),
);
}
/// Re-states [_ecualizadorActivo] (and the current preset's gains) on the
/// native effect now that the platform player is attached again
/// (eq-estado-unico item D). Delegates to [_activarEcualizador], which is
/// already idempotent and already re-asserts the CURRENT value rather than
/// forcing the equalizer on.
Future<void> _reasertarEcualizadorNativo() async {
_reasercionesEcualizador++;
debugPrint(
'[PluriWave][ServicioAudio] reasertando EQ nativo '
'activo=$_ecualizadorActivo',
);
await _activarEcualizador();
}
/// Gestiona cualquier error de reproducción de ExoPlayer.
///
/// Network-class failures while the user still intends to play enter the
@@ -1360,6 +1672,11 @@ class PluriWaveAudioHandler extends BaseAudioHandler
_eq = AndroidEqualizer();
_eqDisponible = false;
// Resets alongside its siblings above: the fresh player starts detached,
// so the next non-idle event is a genuine idle -> active edge that
// [debeReasertarEcualizadorNativo] must see. A value stuck at `true`
// across the rebuild would swallow exactly the re-assert this exists for.
_reproductorActivo = false;
_androidAudioSessionId = null;
_ultimaSessionIdEq = null;
_player = _crearPlayer();
@@ -1384,6 +1701,19 @@ class PluriWaveAudioHandler extends BaseAudioHandler
try {
final params = await _eq.parameters;
_eqDisponible = params.bands.isNotEmpty;
// eq-estado-unico item E: the ONE number that decides whether
// [mapearGananciaNativa] can be silently boosting a FLAT preset on
// this device. `Equalizer.getBandLevelRange()` is not required to be
// symmetric, and nothing else in the app can observe what it returned.
// `debugPrint` (never `dart:developer`'s `log`) so it reaches logcat in
// the release build, which is the only one that ever runs in a car:
//
// adb logcat | grep PluriWave
debugPrint(
'[PluriWave][ServicioAudio] eq rango bandas=${params.bands.length} '
'minDecibels=${params.minDecibels} maxDecibels=${params.maxDecibels} '
'activo=$_ecualizadorActivo preset=${_presetActual.nombre}',
);
if (_eqDisponible) {
await _eq.setEnabled(_ecualizadorActivo);
await aplicarPreset(_presetActual);
@@ -1411,6 +1741,58 @@ class PluriWaveAudioHandler extends BaseAudioHandler
required bool eqDisponible,
}) => sessionId != null && sessionId != ultimaSessionIdEq && eqDisponible;
/// Pure decision for re-asserting the on/off state on the NATIVE effect
/// when the platform player becomes active again (eq-estado-unico item D).
/// No side effects.
///
/// Why it is needed: `just_audio`'s `AudioEffect.setEnabled`
/// (`just_audio.dart:3842-3848`) only reaches the platform while
/// `_player._active` is true. After a `stop()` — or any transition to
/// `idle` — the Dart-side intent is updated but the native effect is not.
/// A user who turns the equalizer off while stopped, then presses play,
/// would get audio that is still equalized with the button reading "off".
///
/// The native effect is treated as WRITE-ONLY throughout: `just_audio`
/// exposes no read-back of `Equalizer.getEnabled()`, so this never
/// compares against the device — it simply re-states the value the app
/// already owns, which is idempotent and cheap.
///
/// [reproductorActivoAntes] is the tracked state BEFORE [estado] arrived,
/// so only the idle -> active edge fires; a player already active does not
/// re-assert on every one of its many events.
@visibleForTesting
static bool debeReasertarEcualizadorNativo({
required ProcessingState estado,
required bool reproductorActivoAntes,
required bool eqDisponible,
}) =>
eqDisponible &&
!reproductorActivoAntes &&
estado != ProcessingState.idle;
/// Forces [_eqDisponible] for a test.
///
/// `_eqDisponible` is only ever set from `AndroidEqualizer.parameters`
/// (see [_activarEcualizador]), whose future only completes on a real
/// device, so off-device it is permanently `false` — and every EQ path
/// worth testing is gated on it. Without this seam
/// [manejarEstadoPlayer]'s re-assert can only ever be exercised on its
/// false branch.
@visibleForTesting
void simularEcualizadorDisponible(bool disponible) {
_eqDisponible = disponible;
}
/// How many times [_reasertarEcualizadorNativo] has actually run.
///
/// The native call it makes is unobservable off-device (see
/// [simularEcualizadorDisponible]), so this counter is the only evidence a
/// test can assert on that the re-assert HAPPENED, rather than that the
/// predicate would have said yes.
@visibleForTesting
int get reasercionesEcualizador => _reasercionesEcualizador;
int _reasercionesEcualizador = 0;
/// Aplica un preset al ecualizador nativo Android.
Future<void> aplicarPreset(PresetEcualizador preset) async {
_presetActual = preset;
@@ -1425,7 +1807,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler
i++
) {
await params.bands[i].setGain(
_mapearGananciaNativa(
mapearGananciaNativa(
preset.bandas[i],
minDecibels: params.minDecibels,
maxDecibels: params.maxDecibels,
@@ -1453,7 +1835,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler
final params = await _eq.parameters;
if (index < params.bands.length) {
await params.bands[index].setGain(
_mapearGananciaNativa(
mapearGananciaNativa(
db,
minDecibels: params.minDecibels,
maxDecibels: params.maxDecibels,
@@ -1463,16 +1845,23 @@ class PluriWaveAudioHandler extends BaseAudioHandler
} catch (_) {}
}
double _mapearGananciaNativa(
double db, {
required double minDecibels,
required double maxDecibels,
}) {
final normalizado = ((db.clamp(-12.0, 12.0) + 12.0) / 24.0).clamp(0.0, 1.0);
return minDecibels + (normalizado * (maxDecibels - minDecibels));
}
/// Sets the equalizer on/off state AND persists it — the single entry
/// point every surface goes through (phone settings via
/// `EstadoEcualizador`, the notification, and the car's [accionEqToggle]).
Future<void> setEcualizadorActivo(bool activo) =>
_aplicarEcualizadorActivo(activo, persistir: true);
Future<void> setEcualizadorActivo(bool activo) async {
/// Adopts a value that came FROM disk (eq-estado-unico item A). Identical
/// to [setEcualizadorActivo] except that it does not write back — seeding
/// is a read, and echoing it to disk would only add a pointless write on
/// every engine start.
Future<void> sembrarEcualizadorActivo(bool activo) =>
_aplicarEcualizadorActivo(activo, persistir: false);
Future<void> _aplicarEcualizadorActivo(
bool activo, {
required bool persistir,
}) async {
_ecualizadorActivo = activo;
if (_eqDisponible) {
try {
@@ -1486,6 +1875,25 @@ class PluriWaveAudioHandler extends BaseAudioHandler
// of WHO toggled it (a car customAction tap or the phone settings
// screen via EstadoEcualizador).
_actualizarControlesEq();
if (!persistir) return;
_eqActivoPersistido = activo;
final guardar = _persistirEqActivo;
if (guardar == null) return;
// eq-estado-unico item B: the handler owns this write now. It used to
// be `EstadoEcualizador._resincronizarConHandler`'s job, which meant a
// toggle made in the car or from the notification was only saved if a
// phone UI object happened to exist — on a headless Android Auto engine
// it never did, so the car toggle was silently lost on every restart.
//
// Failures are swallowed on purpose: a full disk must not turn the
// equalizer button into a crash.
try {
await guardar(activo);
} catch (e) {
debugPrint(
'[PluriWave][ServicioAudio] no se pudo persistir el estado EQ: $e',
);
}
}
Future<void> setVolumen(double vol) async {
@@ -1717,7 +2125,16 @@ class PluriWaveAudioHandler extends BaseAudioHandler
]) async {
switch (name) {
case accionEqToggle:
await setEcualizadorActivo(!_ecualizadorActivo);
final decision = decidirToggleEq(
activoActual: _ecualizadorActivo,
eqDisponible: _eqDisponible,
);
debugPrint(
'[PluriWave][ServicioAudio] customAction $name -> '
'activo=${decision.nuevoActivo} '
'nativo=${decision.requiereLlamadaNativa}',
);
await setEcualizadorActivo(decision.nuevoActivo);
}
}
@@ -1777,8 +2194,23 @@ class PluriWaveAudioHandler extends BaseAudioHandler
if (bloqueada != null) return bloqueada;
final fuenteLocal = _fuenteMusicaLocalGlobal;
if (parentMediaId == AudioService.browsableRootId) {
// fix/android-auto-musica-local: la RAÍZ ya no se decide con el
// round trip de permisos. Ese round trip viaja por
// `pluriwave/file_actions`, cuyo handler nativo solo se registra en
// `MainActivity.configureFlutterEngine` — en el motor headless que
// Android Auto levanta sin Activity no existe, la llamada lanzaba
// `MissingPluginException` y el nodo desaparecía del árbol. Y como
// el head unit CACHEA la raíz, seguía desaparecido toda la sesión.
//
// Ahora solo `noConfigurada` (sin URI persistida, o permiso
// revocado confirmado por el nativo) oculta el nodo;
// `canalNoDisponible` lo mantiene, y es el SUBÁRBOL quien explica
// el problema (`hijosMusicaLocal`) en vez de dejar una carpeta
// vacía.
final incluirMusicaLocal =
fuenteLocal != null && await fuenteLocal.hayCarpetaConfigurada();
fuenteLocal != null &&
await fuenteLocal.estadoCarpeta() !=
EstadoCarpetaLocal.noConfigurada;
return constructor.raiz(
incluirMusicaLocal: incluirMusicaLocal,
premium: premium,
+13 -6
View File
@@ -127,12 +127,19 @@ class ServicioComprasPlayBilling implements PuertoCompras {
void _alRecibirCompras(List<PurchaseDetails> compras) {
if (compras.isEmpty) {
// `restorePurchases()` with nothing to restore completes without ever
// pushing a PurchaseDetails (Spec "Restore finds nothing") — there is
// no per-call correlation in this stream, so this fires on ANY empty
// batch. In practice `queryPastPurchases`/`restorePurchases` on an
// account with nothing to restore is the only source of an empty
// batch this stream would ever emit.
// `restorePurchases()` with nothing to restore pushes an EMPTY batch
// (`in_app_purchase_android` does `_purchaseUpdatedController.add(
// pastPurchases)` unconditionally) — there is no per-call correlation
// in this stream, so this fires on ANY empty batch. In practice
// `restorePurchases` on an account with nothing to restore is the only
// source of an empty batch this stream would ever emit.
//
// Returning silently here (as this did before) left
// [TipoEventoCompra.noEncontrada] NEVER emitted, so
// `EstadoEntitlement._compraEnCurso` stayed `true` forever and
// `hoja_premium.dart` kept BOTH buttons disabled — restore AND buy.
// A paywall that cannot be paid.
_eventos.add(const EventoCompra(TipoEventoCompra.noEncontrada));
return;
}
for (final compra in compras) {
+18
View File
@@ -238,6 +238,24 @@ class ServicioEcualizador {
await prefs.setBool(_keyActivo, activo);
}
/// The persisted equalizer on/off flag, or `null` when the user has never
/// touched the toggle.
///
/// Deliberately narrower than [cargar] (eq-estado-unico item A): it reads
/// ONE key and runs none of the migrations, because its caller is
/// `registrarHandler`, on the audio bootstrap path of EVERY engine —
/// including the headless one Android Auto starts, where there is no
/// widget tree and `EstadoEcualizador` never exists. It must stay cheap
/// and it must never mutate anything.
///
/// `null` is preserved rather than collapsed to a default so that
/// `estadoEqInicial` — not this service — owns the "never persisted"
/// policy in exactly one place.
Future<bool?> leerActivo() async {
final prefs = await _resolverPrefs();
return prefs.getBool(_keyActivo);
}
Future<void> eliminarPorEmisora(String uuid) async {
final prefs = await _resolverPrefs();
final mapa = _leerPresetsPorEmisora(prefs);