Files
pluriwave/lib/servicios/arranque_audio.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

219 lines
9.0 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import '../tema/pluriwave_tokens.dart';
/// Timeout applied to the `AudioService.init` MediaBrowser handshake (Design
/// "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
/// [timeoutArranqueAudio] (Design "Timeout without re-init"). Sealed so
/// callers exhaustively handle both branches.
sealed class ResultadoArranqueAudio<T> {
const ResultadoArranqueAudio();
}
/// The handler future completed within the timeout — normal startup path.
class ArranqueAudioListo<T> extends ResultadoArranqueAudio<T> {
const ArranqueAudioListo(this.handler);
final T handler;
}
/// The handler future did NOT complete within the timeout. [handlerFuturo]
/// is the SAME original future passed to [esperarArranqueAudio] — the
/// caller must keep awaiting it (e.g. via [ArranqueAudioApp]), never start a
/// second `AudioService.init` call (Design "init must never be called
/// twice").
class ArranqueAudioPendiente<T> extends ResultadoArranqueAudio<T> {
const ArranqueAudioPendiente(this.handlerFuturo);
final Future<T> handlerFuturo;
}
/// Races the already-started [handlerFuturo] — a single `AudioService.init`
/// call — against [timeout] (Design "Timeout without re-init"). Returns
/// [ArranqueAudioListo] when [handlerFuturo] resolves in time, otherwise
/// [ArranqueAudioPendiente] wrapping the SAME [handlerFuturo] so it can keep
/// being awaited without ever re-invoking `AudioService.init`.
///
/// [handlerFuturo] and [timeout] are both injected — this function never
/// touches the real `audio_service` plugin, so it is testable with a plain
/// [Future] and short durations.
Future<ResultadoArranqueAudio<T>> esperarArranqueAudio<T>(
Future<T> handlerFuturo, {
Duration timeout = timeoutArranqueAudio,
}) async {
try {
final handler = await handlerFuturo.timeout(timeout);
return ArranqueAudioListo<T>(handler);
} on TimeoutException {
return ArranqueAudioPendiente<T>(handlerFuturo);
}
}
/// Subscribes to [errores] — in production `AudioService.asyncError` — and
/// hands every event to [registrar]. Returns the [StreamSubscription] so the
/// caller can cancel it when the handler is torn down.
///
/// Why this exists: `audio_service` funnels EVERY asynchronous failure of its
/// own observers into that stream and nothing else
/// (`_observePlaybackState`/`_observeMediaItem`/`_observeQueue` each wrap
/// their whole body in `catch (e) { _asyncError.add(e); }`, and the artwork
/// path uses `.catchError(_asyncError.add)`), yet this app had ZERO
/// subscribers on it. A `PublishSubject` with no listeners simply drops
/// events, so the platform-side exception behind "the media notification
/// disappeared" — a rejected `setState`, a failed `setMediaItem`, an
/// Android 12+ `ForegroundServiceStartNotAllowedException` surfacing through
/// the plugin — was being discarded without a single log line. This makes
/// that channel audible.
///
/// [errores] and [registrar] are both injected — this function never touches
/// the real `audio_service` plugin, so it is testable with a plain
/// [StreamController] (same seam convention as [esperarArranqueAudio] above,
/// and as `decidirAvanceCola`/`debeReaplicarEcualizador` elsewhere).
StreamSubscription<Object> observarErroresAudio(
Stream<Object> errores, {
required void Function(Object error) registrar,
}) {
return errores.listen(
registrar,
// The plugin only ever feeds this subject through `add`, never
// `addError`, so this branch is purely defensive: a stream-level error
// would otherwise escape as an unhandled zone error, which is strictly
// worse than one more log line.
onError: (Object error, StackTrace _) => registrar(error),
cancelOnError: false,
);
}
/// Default [observarErroresAudio] logger: one line per swallowed plugin
/// exception.
///
/// Uses [debugPrint], NOT `dart:developer`'s `log`. That distinction is the
/// whole reason this channel existed for weeks without ever producing a
/// single line of evidence: `log()` writes to the VM service, which a
/// RELEASE build does not have, so every exception this was built to catch
/// was still being thrown away — just one layer further down than before.
/// `debugPrint` reaches logcat in release, which is the only build that ever
/// runs in the car.
void registrarErrorAudioService(Object error) {
debugPrint('[PluriWave][ArranqueAudio] AudioService.asyncError: $error');
}
/// Minimal branded bootstrap widget for the degraded path (Design "still
/// call runApp, but with a minimal bootstrap widget that keeps waiting on
/// the SAME original future"). Shows [_CargandoArranqueAudio] while
/// [handlerFuturo] is pending; once it settles, swaps to [construirApp]'s
/// result. On success, [alListo] is called exactly once (handler wiring:
/// `registrarHandler` + `ServicioAudioSession`) before the swap. On an
/// error, [alListo] is never called — the app shell still renders (an
/// infinite spinner is strictly worse) and the error is reported via
/// `FlutterError.reportError` instead of becoming an unhandled exception.
class ArranqueAudioApp<T> extends StatefulWidget {
const ArranqueAudioApp({
super.key,
required this.handlerFuturo,
required this.alListo,
required this.construirApp,
});
/// The SAME future returned by the original `AudioService.init` call —
/// never a new one.
final Future<T> handlerFuturo;
/// Handler-wiring callback (Design "same handler wiring" as the
/// on-time path). Invoked exactly once, when [handlerFuturo] resolves.
final void Function(T handler) alListo;
/// Builds the real app widget once [handlerFuturo] has settled — with the
/// resolved handler on success, or `null` if [handlerFuturo] completed
/// with an error (Design "error path": an infinite spinner is strictly
/// worse than an app shell without the handler wired).
final Widget Function(T? handler) construirApp;
@override
State<ArranqueAudioApp<T>> createState() => _ArranqueAudioAppState<T>();
}
class _ArranqueAudioAppState<T> extends State<ArranqueAudioApp<T>> {
@override
void initState() {
super.initState();
// Attached once in initState (not in build) so alListo runs exactly
// once regardless of how many times FutureBuilder rebuilds below.
unawaited(
widget.handlerFuturo.then(
widget.alListo,
// Design "error path": a post-timeout handler failure must never
// become an unhandled async exception. alListo is deliberately NOT
// called here — it only wires a real handler; build() below still
// swaps away from the spinner via `connectionState == done`
// regardless of hasError, so the app shell renders either way.
onError: (Object error, StackTrace stackTrace) {
FlutterError.reportError(
FlutterErrorDetails(
exception: error,
stack: stackTrace,
library: 'arranque_audio',
context: ErrorDescription(
'esperando handlerFuturo tras el timeout de arranque de audio',
),
),
);
},
),
);
}
@override
Widget build(BuildContext context) {
return FutureBuilder<T>(
future: widget.handlerFuturo,
builder: (context, snapshot) {
// `done` covers BOTH hasData and hasError — an errored handshake
// still swaps to the app shell instead of spinning forever (Design
// "error path").
if (snapshot.connectionState == ConnectionState.done) {
return widget.construirApp(snapshot.data);
}
return const _CargandoArranqueAudio();
},
);
}
}
/// Standalone loading screen shown while [ArranqueAudioApp] waits (Design
/// "minimal branded loading view"): centered `CircularProgressIndicator`,
/// no text — `AppLocalizations` is not available at this point in startup.
/// Self-contained (its own `MaterialApp`) since this can be the direct
/// `runApp()` root.
class _CargandoArranqueAudio extends StatelessWidget {
const _CargandoArranqueAudio();
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
home: Scaffold(
backgroundColor: PluriWaveTokens.dark.deepViolet,
body: const Center(
child: CircularProgressIndicator(color: PluriWaveTokens.brand),
),
),
);
}
}