Files
pluriwave/lib/servicios/arranque_audio.dart
T
FreeTLab b0271fa953 feat(audio): log AudioService.asyncError instead of swallowing it
`AudioService.asyncError` had ZERO subscribers app-wide. The plugin funnels
every asynchronous failure of its own observers into that stream and nowhere
else — `_observePlaybackState`, `_observeMediaItem` and `_observeQueue` each
wrap their whole body in `catch (e) { _asyncError.add(e); }`, and the artwork
path uses `.catchError(_asyncError.add)` — and a `PublishSubject` with no
listeners simply drops what it is given. The platform-side exception behind
"the media playback notification disappeared" was therefore being discarded
without a single log line, which is why that report arrives with no evidence
attached.

`observarErroresAudio` is a pure, injectable seam in `arranque_audio.dart`
(stream in, logger callback out), matching the seam convention this codebase
already uses for `esperarArranqueAudio`, `decidirAvanceCola` and
`debeReaplicarEcualizador`: the unit tests exercise the wiring with a plain
`StreamController`, never the real plugin. The default logger emits one
`[PluriWave]`-prefixed `developer.log` line at `level: 900`, the same level
and prefix `servicio_audio.dart` already uses, so one logcat filter catches
both.

Wired from `lib/main.dart`, not from `arranque_audio.dart`: main.dart is the
module that genuinely owns handler lifecycle — it is the only caller of
`AudioService.init`, `registrarHandler` and `ServicioAudioSession`, and both
the on-time and the degraded/timeout startup branches converge on its
`conectarHandler` closure. `arranque_audio.dart` owns only the timeout race
and the degraded loading shell; it never creates or registers a handler
(`alListo` is injected into it from main.dart), so it has no lifecycle to
hang a subscription on. Subscribing happens before `AudioService.init` — the
getter only touches a static subject — so nothing reported during the
MediaBrowser handshake is missed, and one subscription covers both paths.

The subscription is cancellable and its `cancel` is registered into the
handler via `registrarLimpiezaArranque`, mirroring the existing
`registrarHandler` / `registrarFuenteNavegacion` / `registrarFuenteMusicaLocal`
registration convention. `onTaskRemoved` — the only handler teardown in this
app — runs it, so the subscription cannot outlive what it instruments. The
dependency points bootstrap -> service, so `servicio_audio.dart` never has to
import the bootstrap module or the plugin's static stream.

Zero behaviour change: nothing but log output is added.
2026-08-01 19:15:53 +02:00

212 lines
8.5 KiB
Dart

import 'dart:async';
import 'dart:developer' as developer;
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.
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 `[PluriWave]`-prefixed
/// `developer.log` line per swallowed plugin exception, at the same
/// `level: 900` (SEVERE) that `servicio_audio.dart`'s existing error lines
/// use, so a single logcat/DevTools filter catches both.
void registrarErrorAudioService(Object error) {
developer.log(
'[PluriWave] AudioService.asyncError: $error',
name: 'ArranqueAudio',
level: 900,
);
}
/// 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),
),
),
);
}
}