Adds a permanent, non-consumable premium unlock (EstadoEntitlement + PuertoCompras/ServicioComprasPlayBilling) that removes ads and unlocks alarm vacations, alarms past a 5-alarm free cap, recording start, and full Android Auto browsing. The phone equalizer stays free for everyone. - Entitlement is prefs-backed (compra_premium_v1), fail-open, and resolvable headlessly via esPremiumPersistido() for the Android Auto audio handler, which registers before runApp. - Android Auto reduced mode keeps the real root folder labels for free users; browsing into any of them (and playFromMediaId/playFromSearch/ skipToNext/skipToPrevious) is blocked at the getChildren/servicio_audio choke points, with a locked "Función Premium" item as the backstop. Current-station play/pause/stop stays untouched. A free -> premium transition actively invalidates the head unit's cached browse tree. - Ads (top banner + capped interstitial before adding a station or an alarm) are gated behind entitlement via ServicioAnuncios, using official Google test ad unit IDs pending AdMob provisioning. - Alarm cap UX shows an explanatory message with a secondary unlock action rather than a bare paywall jump; existing data is grandfathered. - 4 new localization keys translated across all 13 supported locales. Co-located tests use strict TDD (RED test before implementation) for every new pure-logic unit; full existing suite passes unchanged.
286 lines
12 KiB
Dart
286 lines
12 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 '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_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).
|
|
unawaited(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;
|
|
}
|