Fixes 9 of 10 review findings (10th requires a manual Play Console step, no code change): 1. app.dart/banner_anuncio_superior.dart: move the top SafeArea inside BannerAnuncioSuperior so it only reserves status-bar height when an ad actually renders, restoring edge-to-edge layout for premium and free-unloaded users. 2. servicio_anuncios.dart: bound every interstitial await (load, presentation, and the injected implementation itself) with injectable timeouts so a callback that never fires can no longer hang a caller. 3. estado_entitlement.dart/hoja_premium.dart: expose a typed resultadoUsuario signal for purchase/restore failures and restore-found-nothing, with dedicated localized messages (compraError, restauracionSinCompras) across all 13 locales -- never the raw developer/exception string. 4. main.dart/servicio_consentimiento.dart: add a GDPR/UMP consent flow (ConsentInformation/ConsentForm) that gates Mobile Ads SDK init on canRequestAds(); premium users never see a consent form; failures degrade to no ads instead of crashing or blocking startup. 6. servicio_anuncios.dart: track real ad presentation (onAdShowedFullScreenContent) so a failed-to-show interstitial no longer consumes a session cap slot. 7. banner_anuncio_superior.dart: add an explicit load-attempted guard so repeated didChangeDependencies (e.g. entitlement notifyListeners during a purchase) can only ever trigger one banner load attempt. 8. servicio_anuncios.dart: make esPremium a required constructor parameter, matching the hardened contract already applied to EstadoAlarmas/EstadoGrabacion/EstadoRadio. 9. hoja_premium.dart: add a dedicated premiumActivo localized string instead of reusing the equalizer's equalizerActive translation, across all 13 locales. All fixes implemented RED-first (failing test before production code). Full suite: 1261 passed, 2 pre-existing skips, 0 failures. flutter analyze: 5 pre-existing issues only, 0 new. [version set]
312 lines
14 KiB
Dart
312 lines
14 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_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.hayCarpetaConfigurada()`.
|
|
// 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);
|
|
|
|
// 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);
|
|
// 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,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _OrientacionResponsiveApp extends StatefulWidget {
|
|
const _OrientacionResponsiveApp({required this.child});
|
|
|
|
final Widget child;
|
|
|
|
@override
|
|
State<_OrientacionResponsiveApp> createState() =>
|
|
_OrientacionResponsiveAppState();
|
|
}
|
|
|
|
class _OrientacionResponsiveAppState extends State<_OrientacionResponsiveApp>
|
|
with WidgetsBindingObserver {
|
|
ui.Display? _display;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addObserver(this);
|
|
}
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
_display = View.maybeOf(context)?.display;
|
|
unawaited(aplicarPoliticaOrientacion(display: _display));
|
|
}
|
|
|
|
@override
|
|
void didChangeMetrics() {
|
|
unawaited(aplicarPoliticaOrientacion(display: _display));
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
WidgetsBinding.instance.removeObserver(this);
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) => widget.child;
|
|
}
|