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.
410 lines
18 KiB
Dart
410 lines
18 KiB
Dart
import 'dart:async';
|
|
import 'dart:developer' as developer;
|
|
import 'dart:ui' as ui;
|
|
|
|
import 'package:audio_service/audio_service.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:google_mobile_ads/google_mobile_ads.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import 'app.dart';
|
|
import 'estado/estado_entitlement.dart';
|
|
import 'servicios/arranque_audio.dart';
|
|
import 'servicios/musica_local_auto.dart';
|
|
import 'servicios/navegacion_auto.dart';
|
|
import 'servicios/servicio_audio.dart';
|
|
import 'servicios/servicio_audio_session.dart';
|
|
import 'servicios/servicio_compras.dart';
|
|
import 'servicios/servicio_consentimiento.dart';
|
|
import 'servicios/servicio_ecualizador.dart';
|
|
import 'servicios/servicio_presets_personalizados.dart';
|
|
import 'tema/pluriwave_tokens.dart';
|
|
|
|
const _anchoMinimoLandscape = 600.0;
|
|
|
|
/// Branded monochrome status-bar icon, replacing the default full-color
|
|
/// launcher silhouette fallback.
|
|
const androidNotificationIconResource = 'drawable/ic_stat_pluriwave';
|
|
|
|
/// S5-R8: media notification accent uses the brand color, not the M3
|
|
/// default purple. Top-level const so tests can assert it.
|
|
const configuracionAudioService = AudioServiceConfig(
|
|
androidNotificationChannelId: 'es.freetimelab.pluriwave.audio',
|
|
androidNotificationChannelName: 'PluriWave Radio',
|
|
// Paired with `androidStopForegroundOnPause: false` below, and required to
|
|
// be: the plugin asserts `androidNotificationOngoing` implies
|
|
// `androidStopForegroundOnPause`. Nothing is lost by turning it off —
|
|
// while the service is in the foreground the OS forces the notification to
|
|
// be ongoing anyway, which is now the whole time playback is alive.
|
|
androidNotificationOngoing: false,
|
|
// The service stays in the FOREGROUND while paused.
|
|
//
|
|
// With `true`, a pause called `stopForeground(...)`, and a service that is
|
|
// not in the foreground is a service Android may kill at will. In the car
|
|
// that is exactly what happened: an interruption paused playback, the
|
|
// service dropped out of the foreground, Android reclaimed it, and
|
|
// PluriWave disappeared from the Android Auto pane — another media app
|
|
// took the slot. Ducking (see `ServicioAudioSession.configurar`) removes
|
|
// most pauses, but a real pause must not be a death sentence either.
|
|
//
|
|
// The plugin's own doc for this flag says it outright: «while in this
|
|
// lower priority state, the operating system will also be able to kill
|
|
// your service at any time to reclaim resources».
|
|
//
|
|
// Cost of `false`: the notification is not swipe-dismissible while paused,
|
|
// only after Stop. That is how every serious media app behaves, and Stop
|
|
// still tears everything down.
|
|
androidStopForegroundOnPause: false,
|
|
notificationColor: PluriWaveTokens.brand,
|
|
androidNotificationIcon: androidNotificationIconResource,
|
|
);
|
|
|
|
Future<void> main() async {
|
|
WidgetsFlutterBinding.ensureInitialized();
|
|
|
|
// Android Auto browse source: registered FIRST, before any await at all.
|
|
// It depends on nothing, and everything below it is a potential place to
|
|
// get stuck — so nothing may sit between engine start and this line.
|
|
//
|
|
// Reported: with Android Auto connected, the car screen sometimes came up
|
|
// black and the app then opened WHITE on the phone until it was
|
|
// force-killed. `AudioServiceActivity.provideFlutterEngine` returns the
|
|
// engine from `AudioServicePlugin.getFlutterEngine`, which CREATES the
|
|
// engine and runs `main()` headlessly the first time — with no Activity —
|
|
// when the car binds the MediaBrowserService before the app is opened.
|
|
// `_aplicarPoliticaOrientacion` used to be the first `await` here, and
|
|
// `SystemChrome.setPreferredOrientations` travels the `flutter/platform`
|
|
// channel, whose handler (`PlatformPlugin`) is installed by the Activity.
|
|
// Headless there is nobody to answer it, so `main()` died or hung on line
|
|
// one: the browse source below was never registered (`getChildren` had no
|
|
// source -> black car screen) and `runApp` was never reached. Opening the
|
|
// app then REUSED that same cached, already-dead engine -> white screen,
|
|
// and only a force-kill (which disposes the engine) recovered it.
|
|
final fuenteAuto = FuenteEmisorasAutoLocal();
|
|
registrarFuenteNavegacion(fuenteAuto);
|
|
|
|
// Local music registers HERE, above every await, alongside the station
|
|
// source — not after `SharedPreferences.getInstance()` where it used to
|
|
// sit.
|
|
//
|
|
// Regression this fixes, self-inflicted by the reordering above: the root
|
|
// menu decides whether to offer "Música Local" with
|
|
// `fuenteLocal != null && await fuenteLocal.estadoCarpeta() != noConfigurada`.
|
|
// Moving ONLY the station source above the awaits meant the car could get
|
|
// a root response in the window before this line ran, find a null source,
|
|
// and be told there is no local music — and Android Auto caches the browse
|
|
// root, so it stayed missing for the whole session. Before the reorder
|
|
// both registrations sat together after the await, so the window did not
|
|
// exist.
|
|
//
|
|
// `FuenteMusicaLocalAutoImpl` needs no prefs to be CONSTRUCTED: it
|
|
// resolves them lazily per call (`_resolverPrefs`, falling back to
|
|
// `getInstance()`), the same convention `ServicioAlarmas` uses. So there
|
|
// was never a reason for it to wait on that await.
|
|
registrarFuenteMusicaLocal(FuenteMusicaLocalAutoImpl());
|
|
|
|
// Cosmetic, and deliberately NOT awaited: a display preference must never
|
|
// gate `runApp`. `OrientacionResponsiveApp.didChangeDependencies` applies
|
|
// it again as soon as a real view exists, which is the only moment it can
|
|
// actually take effect anyway.
|
|
unawaited(aplicarPoliticaOrientacion());
|
|
|
|
// iap-freemium-unlock: neither SDK init call blocks `runApp` — a purchase
|
|
// stream subscription and an ad-SDK warm-up are both safe to finish late
|
|
// (mirrors `aplicarPoliticaOrientacion`'s "cosmetic, never gates startup"
|
|
// rule immediately above).
|
|
//
|
|
// FIX 4 (code review): the Mobile Ads SDK is only initialized AFTER the
|
|
// GDPR/UMP consent flow resolves that ads may actually be requested
|
|
// (`ConsentInformation.canRequestAds()`) — serving personalized ads to
|
|
// EEA/UK users with no CMP violates Google's EU User Consent Policy.
|
|
// Premium users never even reach the consent form (`resolverConsentimientoAnuncios`
|
|
// short-circuits for them — they get zero ads regardless). This whole
|
|
// chain is deliberately `unawaited`: consent/ads are exactly as
|
|
// "cosmetic, never gates startup" as `aplicarPoliticaOrientacion` above,
|
|
// and any failure inside it degrades to "no ads", never a crash or a
|
|
// blocked UI.
|
|
unawaited(
|
|
esPremiumPersistido()
|
|
.then(
|
|
(premium) => resolverConsentimientoAnuncios(
|
|
esPremium: premium,
|
|
consentimiento: ServicioConsentimientoUmp(),
|
|
),
|
|
)
|
|
.then((puedeSolicitarAnuncios) async {
|
|
if (puedeSolicitarAnuncios) {
|
|
await MobileAds.instance.initialize();
|
|
}
|
|
}),
|
|
);
|
|
final compras = ServicioComprasPlayBilling();
|
|
|
|
// S3-R4: single SharedPreferences instance resolved once at startup and
|
|
// injected into every state/service below.
|
|
final prefs = await SharedPreferences.getInstance();
|
|
|
|
// User-saved EQ presets for the car's Ecualizador folder, same
|
|
// injectable-prefs DI convention and same pre-init placement as the two
|
|
// registrations above (neither depends on the AudioHandler). Passed as a
|
|
// read function, not the service, so the folder re-reads on every browse:
|
|
// a preset saved on the phone appears in the car without an app restart.
|
|
final presetsPersonalizados = ServicioPresetsPersonalizados(prefs: prefs);
|
|
registrarFuentePresetsPersonalizados(presetsPersonalizados.listar);
|
|
|
|
// eq-estado-unico items A/B: the handler's own link to the equalizer's
|
|
// persisted on/off flag. `ServicioEcualizador` needs nothing but the
|
|
// `prefs` instance resolved just above — no widget tree, no Provider — so
|
|
// it is available on EVERY engine, including the headless one Android Auto
|
|
// starts. Before this, the persisted value only reached the handler
|
|
// through `EstadoEcualizador.cargarPersistido()`, which that engine never
|
|
// runs: the handler played with the equalizer forced on while disk and the
|
|
// phone UI both said off, and a toggle made in the car was lost on
|
|
// restart. Passed as two narrow function ports, mirroring the
|
|
// read-function convention used for the preset folder right above.
|
|
final ecualizador = ServicioEcualizador(prefs: prefs);
|
|
|
|
// Silent-error channel (fix/notificacion-media): `AudioService.asyncError`
|
|
// had ZERO subscribers app-wide, and a `PublishSubject` with no listeners
|
|
// drops what it is given — so every exception `audio_service` catches
|
|
// internally was discarded without a trace, which is exactly why the
|
|
// "media notification disappeared" report came with no evidence attached.
|
|
// Subscribed BEFORE `AudioService.init` below (the getter only touches a
|
|
// static subject, so it needs no initialisation) so nothing reported
|
|
// during the MediaBrowser handshake is missed, and placed here rather than
|
|
// in `conectarHandler` so ONE subscription covers both the on-time and the
|
|
// degraded/timeout startup paths.
|
|
final subErroresAudio = observarErroresAudio(
|
|
AudioService.asyncError,
|
|
registrar: registrarErrorAudioService,
|
|
);
|
|
|
|
// Design "Timeout without re-init": AudioService.init is started exactly
|
|
// ONCE here and `handlerFuturo` is the only future ever awaited for it —
|
|
// the plugin caches state internally, so a double-configure call is
|
|
// unsafe and must never happen, even on the degraded/timeout path below.
|
|
final handlerFuturo = AudioService.init(
|
|
builder: () => PluriWaveAudioHandler(),
|
|
config: configuracionAudioService,
|
|
);
|
|
|
|
// S3-R1: audio focus — phone calls / transient losses pause or duck the
|
|
// radio; headphones unplugged pauses it. Shared by both the on-time and
|
|
// degraded/late-completion paths below.
|
|
void conectarHandler(PluriWaveAudioHandler handler) {
|
|
registrarHandler(
|
|
handler,
|
|
leerEqActivoPersistido: ecualizador.leerActivo,
|
|
guardarEqActivoPersistido: ecualizador.guardarActivo,
|
|
);
|
|
// The handler is the only thing this app ever tears down
|
|
// (`onTaskRemoved`), so the asyncError subscription's `cancel` travels
|
|
// with it and can never leak — same "register from main.dart" convention
|
|
// as `registrarHandler` itself.
|
|
registrarLimpiezaArranque(subErroresAudio.cancel);
|
|
final sesionAudio = ServicioAudioSession(objetivo: handler);
|
|
unawaited(sesionAudio.configurar());
|
|
}
|
|
|
|
Widget construirApp() => OrientacionResponsiveApp(
|
|
child: PluriWaveApp(prefs: prefs, fuenteAuto: fuenteAuto, compras: compras),
|
|
);
|
|
|
|
final resultado = await esperarArranqueAudio(handlerFuturo);
|
|
switch (resultado) {
|
|
case ArranqueAudioListo<PluriWaveAudioHandler>(:final handler):
|
|
// Handshake finished in time — exactly today's startup path.
|
|
conectarHandler(handler);
|
|
runApp(construirApp());
|
|
case ArranqueAudioPendiente<PluriWaveAudioHandler>(
|
|
handlerFuturo: final futuroPendiente,
|
|
):
|
|
// Handshake still hung after the timeout: run the app anyway with a
|
|
// minimal loading bootstrap that keeps awaiting the SAME
|
|
// `futuroPendiente` — identical to the outer `handlerFuturo`, per
|
|
// esperarArranqueAudio's contract (never a second AudioService.init
|
|
// call) — and wires the handler + swaps to the real app once it
|
|
// eventually resolves.
|
|
runApp(
|
|
ArranqueAudioApp<PluriWaveAudioHandler>(
|
|
handlerFuturo: futuroPendiente,
|
|
alListo: conectarHandler,
|
|
construirApp: (_) => construirApp(),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Which orientations a display [anchoLogico] dp wide may use: phones stay
|
|
/// portrait, tablets get everything. Pure, so the policy is testable without
|
|
/// a platform channel.
|
|
@visibleForTesting
|
|
List<DeviceOrientation> orientacionesPara(double anchoLogico) =>
|
|
anchoLogico < _anchoMinimoLandscape
|
|
? const [DeviceOrientation.portraitUp]
|
|
: DeviceOrientation.values;
|
|
|
|
/// Applies [orientacionesPara] to the active display.
|
|
///
|
|
/// NEVER throws and never blocks a caller that matters. This runs on the
|
|
/// headless engine Android Auto starts (see `main`), where the
|
|
/// `flutter/platform` channel has no handler because there is no Activity to
|
|
/// install `PlatformPlugin` — so the call can fail with a
|
|
/// `MissingPluginException` or simply never be answered. Before this guard
|
|
/// that outcome killed `main()` outright, taking the Android Auto browse
|
|
/// registration and `runApp` with it.
|
|
///
|
|
/// [aplicar] is injectable so the swallow-everything contract is testable
|
|
/// without a real platform channel.
|
|
@visibleForTesting
|
|
Future<void> aplicarPoliticaOrientacion({
|
|
ui.Display? display,
|
|
Future<void> Function(List<DeviceOrientation>)? aplicar,
|
|
}) async {
|
|
try {
|
|
final vista =
|
|
WidgetsBinding.instance.platformDispatcher.views.isNotEmpty
|
|
? WidgetsBinding.instance.platformDispatcher.views.first
|
|
: null;
|
|
final displayActivo = display ?? vista?.display;
|
|
if (displayActivo == null) return;
|
|
|
|
final anchoLogico =
|
|
displayActivo.size.width / displayActivo.devicePixelRatio;
|
|
await (aplicar ?? SystemChrome.setPreferredOrientations)(
|
|
orientacionesPara(anchoLogico),
|
|
);
|
|
} catch (e) {
|
|
// Deliberately broad: a cosmetic preference is never worth a failed
|
|
// startup, and headless is exactly where this fails.
|
|
developer.log(
|
|
'[PluriWave] no se pudo aplicar la política de orientación: $e',
|
|
name: 'Arranque',
|
|
level: 900,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Whether the Android Auto browse tree must be invalidated right now
|
|
/// (fix/android-auto-musica-local, item 4 — CORRECTED trigger).
|
|
///
|
|
/// The trigger used to be `View.maybeOf(context) != null` inside
|
|
/// `didChangeDependencies`, latched once, on the premise that «a View means
|
|
/// there is an Activity». That premise is FALSE: `runApp` unconditionally
|
|
/// wraps the tree in a `View` built from
|
|
/// `platformDispatcher.implicitView` and throws a `StateError` when there is
|
|
/// none (`flutter/lib/src/widgets/binding.dart`, `wrapWithDefaultView`). So
|
|
/// on the headless `audio_service` engine — which demonstrably reaches
|
|
/// `runApp`, see [aplicarPoliticaOrientacion] — the View is ALREADY there at
|
|
/// the first `didChangeDependencies`. The one-shot latch was spent at the
|
|
/// exact moment it could accomplish nothing (`_childrenSubjects` still
|
|
/// empty, so `notificarHijosCambiaron` is a silent no-op) and could never
|
|
/// fire again, because `didChangeDependencies` does not re-run when an
|
|
/// Activity later attaches to that same cached engine.
|
|
///
|
|
/// Two conditions replace it, both required:
|
|
///
|
|
/// * [estado] is [AppLifecycleState.resumed] — the only state that genuinely
|
|
/// means «an Activity is attached and in the foreground». It reaches Dart
|
|
/// exclusively through `SystemChannels.lifecycle` (or
|
|
/// `PlatformDispatcher.initialLifecycleState`, which buffers the same
|
|
/// messages), and on Android only `LifecycleChannel.appIsResumed()` sends
|
|
/// it, driven by the Activity's own `onResume`.
|
|
/// `AudioServicePlugin.getFlutterEngine` builds its engine from the
|
|
/// APPLICATION context and runs the Dart entrypoint immediately, with no
|
|
/// Activity and no `FlutterActivityAndFragmentDelegate`, so nothing sends
|
|
/// it on the headless engine.
|
|
/// * [hayCocheSuscrito] — a head unit has actually subscribed to at least
|
|
/// one browse id (`hayCocheSuscritoAlArbol`). This is what makes the latch
|
|
/// worth spending, and it is also the belt to `resumed`'s braces: even if
|
|
/// a lifecycle event did somehow arrive during a headless cold start,
|
|
/// nothing has subscribed yet, so the latch survives for the moment an
|
|
/// Activity really does attach.
|
|
///
|
|
/// [yaInvalidado] keeps it one-shot: an app foregrounded twenty times must
|
|
/// not send twenty `notifyChildrenChanged` storms to the car.
|
|
///
|
|
/// Pure, so the whole policy is testable without an engine.
|
|
@visibleForTesting
|
|
bool debeInvalidarArbolAutoAlReanudar({
|
|
required AppLifecycleState estado,
|
|
required bool hayCocheSuscrito,
|
|
required bool yaInvalidado,
|
|
}) =>
|
|
!yaInvalidado && hayCocheSuscrito && estado == AppLifecycleState.resumed;
|
|
|
|
/// Root wrapper that keeps the orientation policy applied and owns the
|
|
/// Android Auto browse-tree recovery hook.
|
|
///
|
|
/// Public only so a test can mount it and drive real lifecycle events
|
|
/// through [debeInvalidarArbolAutoAlReanudar]'s call site — the previous
|
|
/// trigger shipped broken precisely because nothing could reach it.
|
|
@visibleForTesting
|
|
class OrientacionResponsiveApp extends StatefulWidget {
|
|
const OrientacionResponsiveApp({super.key, required this.child});
|
|
|
|
final Widget child;
|
|
|
|
@override
|
|
State<OrientacionResponsiveApp> createState() =>
|
|
_OrientacionResponsiveAppState();
|
|
}
|
|
|
|
class _OrientacionResponsiveAppState extends State<OrientacionResponsiveApp>
|
|
with WidgetsBindingObserver {
|
|
ui.Display? _display;
|
|
|
|
/// fix/android-auto-musica-local, item 4: la invalidación del árbol del
|
|
/// coche se dispara UNA sola vez. Ver
|
|
/// [debeInvalidarArbolAutoAlReanudar].
|
|
bool _arbolAutoInvalidado = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addObserver(this);
|
|
}
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
_display = View.maybeOf(context)?.display;
|
|
unawaited(aplicarPoliticaOrientacion(display: _display));
|
|
}
|
|
|
|
/// `resumed` es lo único que significa de verdad «ya hay Activity
|
|
/// adjunta», y con ella el handler nativo de `pluriwave/file_actions` que
|
|
/// `MainActivity.configureFlutterEngine` instala. Si el coche había
|
|
/// navegado la raíz ANTES (arranque headless), la cacheó sin poder
|
|
/// resolver la música local; Android Auto no vuelve a preguntar por su
|
|
/// cuenta, así que se lo decimos aquí. Ver
|
|
/// [debeInvalidarArbolAutoAlReanudar] para las tres condiciones.
|
|
@override
|
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
|
super.didChangeAppLifecycleState(state);
|
|
if (!debeInvalidarArbolAutoAlReanudar(
|
|
estado: state,
|
|
hayCocheSuscrito: hayCocheSuscritoAlArbol(),
|
|
yaInvalidado: _arbolAutoInvalidado,
|
|
)) {
|
|
return;
|
|
}
|
|
_arbolAutoInvalidado = true;
|
|
invalidarArbolAuto();
|
|
}
|
|
|
|
@override
|
|
void didChangeMetrics() {
|
|
unawaited(aplicarPoliticaOrientacion(display: _display));
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
WidgetsBinding.instance.removeObserver(this);
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) => widget.child;
|
|
}
|