Files
pluriwave/lib/main.dart
T
FreeTLab aa0b242374 feat(iap): add freemium unlock via one-time in-app purchase
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.
2026-08-10 20:37:07 +02:00

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;
}