Every diagnostic line in the audio path used `dart:developer`'s `log()`. That function writes to the VM service, which a RELEASE build does not have — so in the only build that ever runs in a car, all eleven of them went nowhere. `debugPrint`/`print` do reach logcat in release; `log()` does not. That includes the two channels built specifically to end the guessing: - `registrarErrorAudioService`, which subscribes to `AudioService.asyncError` so the plugin's swallowed platform exceptions stop vanishing (b0271fa). It moved them from a dropped PublishSubject to a dropped log call. - `_trazarEstadoPublicado`, the published-state trace added in7054a4cto settle why the car's play button never becomes pause. So "no evidence" was never a quiet app. It was an app writing its evidence somewhere release builds discard. Several rounds of hypotheses were argued without data that the app was already producing. All eleven now use `debugPrint` with a `[PluriWave][Tag]` prefix, so one filter catches the audio path and the existing alarm lines together: adb logcat | grep PluriWave No behaviour changes. Tests: 1158, unchanged.
213 lines
8.7 KiB
Dart
213 lines
8.7 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 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 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),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|