`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.
212 lines
8.5 KiB
Dart
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),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|