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.
219 lines
9.0 KiB
Dart
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),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|