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.
This commit is contained in:
@@ -4,7 +4,9 @@ import 'dart:ui' show Locale;
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/foundation.dart' show debugPrint, visibleForTesting;
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
|
||||
import '../estado/estado_entitlement.dart' show esPremiumPersistido;
|
||||
import '../l10n/display_names.dart';
|
||||
import '../l10n/gen/app_localizations.dart';
|
||||
import '../modelos/emisora.dart';
|
||||
@@ -36,6 +38,17 @@ PluriWaveAudioHandler? _handlerGlobal;
|
||||
|
||||
void registrarHandler(PluriWaveAudioHandler handler) {
|
||||
_handlerGlobal = handler;
|
||||
// iap-freemium-unlock (design.md Open Questions, orchestrator-resolved):
|
||||
// on the free -> premium transition, actively invalidate every root-level
|
||||
// browse id a head unit may have cached while locked, rather than waiting
|
||||
// for its own re-bind — see [registrarNotificacionDesbloqueoAuto]'s doc.
|
||||
registrarNotificacionDesbloqueoAuto(() {
|
||||
handler.notificarHijosCambiaron(AudioService.browsableRootId);
|
||||
handler.notificarHijosCambiaron(ConstructorArbolAuto.idFavoritos);
|
||||
handler.notificarHijosCambiaron(ConstructorArbolAuto.idTodas);
|
||||
handler.notificarHijosCambiaron(ConstructorArbolAuto.idMisEmisoras);
|
||||
handler.notificarHijosCambiaron(ConstructorArbolAuto.idMusicaLocal);
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -140,6 +153,41 @@ void registrarLimpiezaArranque(Future<void> Function() limpieza) {
|
||||
_limpiezaArranqueGlobal = limpieza;
|
||||
}
|
||||
|
||||
/// Free -> premium Android Auto cache-invalidation hook (design.md Open
|
||||
/// Questions, orchestrator-resolved): registered from [registrarHandler] so
|
||||
/// `estado_entitlement.dart` can trigger it WITHOUT ever touching
|
||||
/// `PluriWaveAudioHandler` directly (that type cannot be constructed in a
|
||||
/// unit test — see [PluriWaveAudioHandler]'s own doc). `null` until a
|
||||
/// handler registers (headless cold bind, or a widget-only test that never
|
||||
/// wires audio) — [notificarDesbloqueoAuto] tolerates that silently.
|
||||
void Function()? _alDesbloquearAutoGlobal;
|
||||
|
||||
/// Registers the hook [notificarDesbloqueoAuto] invokes. Exposed at module
|
||||
/// level (like every other `registrar*` seam in this file) purely so tests
|
||||
/// can inject a fake hook and assert it fires, without instantiating a real
|
||||
/// [PluriWaveAudioHandler].
|
||||
void registrarNotificacionDesbloqueoAuto(void Function() alDesbloquear) {
|
||||
_alDesbloquearAutoGlobal = alDesbloquear;
|
||||
}
|
||||
|
||||
/// Fires the registered free -> premium Android Auto invalidation hook, if
|
||||
/// any. A no-op before a handler ever registers — never throws.
|
||||
void notificarDesbloqueoAuto() {
|
||||
_alDesbloquearAutoGlobal?.call();
|
||||
}
|
||||
|
||||
/// Pure Android Auto play-path gate decision (iap-freemium-unlock, Design
|
||||
/// ADR-4): whether a station-switch dispatch (`playFromMediaId`,
|
||||
/// `playFromSearch`, `skipToNext`, `skipToPrevious`) must no-op for
|
||||
/// [premium]. This is the mandatory BACKSTOP alongside
|
||||
/// `respuestaBloqueadaPorEntitlement` (`navegacion_auto.dart`) — gating
|
||||
/// `getChildren` alone would leave a stale/cached `emisora:<uuid>` tap free
|
||||
/// to bypass browsing entirely (android-auto-media spec "Free-Tier Browse
|
||||
/// Never Leaks Real Content"). Deliberately does NOT gate `play`/`pause`/
|
||||
/// `stop` — transport control of whatever is ALREADY loaded stays free
|
||||
/// (Spec "Current-Station Playback Unaffected By Free Tier").
|
||||
bool debeBloquearCambioDeEmisora({required bool premium}) => !premium;
|
||||
|
||||
/// Builds the phone-initiated "play a station" `MediaItem` (item 3, Android
|
||||
/// Auto fallback artwork): reuses [artUriPara] (`navegacion_auto.dart`) so a
|
||||
/// station with no usable favicon gets the SAME on-brand rotating fallback
|
||||
@@ -638,6 +686,32 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
/// Reconnect-on-stall state machine (Design 7.2, S7-R2).
|
||||
final ControladorReconexion _reconexion = ControladorReconexion();
|
||||
|
||||
/// Per-`parentMediaId` "children changed" subjects (iap-freemium-unlock,
|
||||
/// design.md Open Questions): `audio_service`'s OWN internal listener
|
||||
/// (registered once `AudioService.init` completes) subscribes to
|
||||
/// [subscribeToChildren] and forwards every new value to the platform's
|
||||
/// `notifyChildrenChanged` — the plugin's top-level `notifyChildrenChanged`
|
||||
/// helper is deprecated precisely in favor of this stream-based path. A
|
||||
/// `BehaviorSubject` per id, created lazily on first subscription;
|
||||
/// [notificarHijosCambiaron] pushes a fresh (empty, content-agnostic)
|
||||
/// value to trigger the platform notification for that id.
|
||||
final _childrenSubjects = <String, BehaviorSubject<Map<String, dynamic>>>{};
|
||||
|
||||
@override
|
||||
ValueStream<Map<String, dynamic>> subscribeToChildren(String parentMediaId) =>
|
||||
_childrenSubjects.putIfAbsent(
|
||||
parentMediaId,
|
||||
() => BehaviorSubject<Map<String, dynamic>>.seeded(<String, dynamic>{}),
|
||||
);
|
||||
|
||||
/// Invalidates a head unit's cached browse listing for [parentMediaId]
|
||||
/// (Design "Open Questions" — actively invalidate on the free -> premium
|
||||
/// transition rather than waiting for the head unit's own re-bind). A
|
||||
/// no-op if nothing ever subscribed to this id.
|
||||
void notificarHijosCambiaron(String parentMediaId) {
|
||||
_childrenSubjects[parentMediaId]?.add(<String, dynamic>{});
|
||||
}
|
||||
|
||||
/// True while the handler is inside the reconnect window. [ServicioAudio]
|
||||
/// maps it to [EstadoReproduccion.reconectando] so the UI shows a loading
|
||||
/// indicator instead of an error during retries (S7-R3).
|
||||
@@ -1521,6 +1595,12 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
/// and a button that is present but inert is worse than no button.
|
||||
@override
|
||||
Future<void> skipToNext() async {
|
||||
// iap-freemium-unlock (Design ADR-4 backstop): station-to-station
|
||||
// skipping is a browse/switch action, blocked for free tier regardless
|
||||
// of queue state. Current-station play/pause/stop is untouched.
|
||||
if (debeBloquearCambioDeEmisora(premium: await esPremiumPersistido())) {
|
||||
return;
|
||||
}
|
||||
final cola = _colaLocal;
|
||||
if (cola == null) {
|
||||
if (_reproduciendoRadio) await _saltarEmisora(haciaAtras: false);
|
||||
@@ -1542,6 +1622,10 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
/// [skipToNext].
|
||||
@override
|
||||
Future<void> skipToPrevious() async {
|
||||
// iap-freemium-unlock (Design ADR-4 backstop): mirrors [skipToNext].
|
||||
if (debeBloquearCambioDeEmisora(premium: await esPremiumPersistido())) {
|
||||
return;
|
||||
}
|
||||
final cola = _colaLocal;
|
||||
if (cola == null) {
|
||||
if (_reproduciendoRadio) await _saltarEmisora(haciaAtras: true);
|
||||
@@ -1640,6 +1724,9 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
await _androidAudioSessionIdSub?.cancel();
|
||||
await _player.dispose();
|
||||
await _androidAudioSessionIdController.close();
|
||||
for (final subject in _childrenSubjects.values) {
|
||||
await subject.close();
|
||||
}
|
||||
// Handler teardown: release the bootstrap-owned `AudioService.asyncError`
|
||||
// subscription too, so it cannot outlive the handler it was instrumenting.
|
||||
// Never throws out of teardown — a failing cleanup hook must not prevent
|
||||
@@ -1670,11 +1757,25 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
]) async {
|
||||
try {
|
||||
final constructor = ConstructorArbolAuto();
|
||||
// iap-freemium-unlock (Design ADR-4): the AUTHORITATIVE entitlement
|
||||
// gate, resolved ONCE per call and checked BEFORE any other
|
||||
// resolution — the backstop against a stale/deep-linked non-root id
|
||||
// (android-auto-media spec "Free-Tier Browse Never Leaks Real
|
||||
// Content"). Never blocks the root itself (see that function's doc).
|
||||
final premium = await esPremiumPersistido();
|
||||
final bloqueada = respuestaBloqueadaPorEntitlement(
|
||||
parentMediaId: parentMediaId,
|
||||
premium: premium,
|
||||
);
|
||||
if (bloqueada != null) return bloqueada;
|
||||
final fuenteLocal = _fuenteMusicaLocalGlobal;
|
||||
if (parentMediaId == AudioService.browsableRootId) {
|
||||
final incluirMusicaLocal =
|
||||
fuenteLocal != null && await fuenteLocal.hayCarpetaConfigurada();
|
||||
return constructor.raiz(incluirMusicaLocal: incluirMusicaLocal);
|
||||
return constructor.raiz(
|
||||
incluirMusicaLocal: incluirMusicaLocal,
|
||||
premium: premium,
|
||||
);
|
||||
}
|
||||
final musicaLocal = await hijosMusicaLocal(
|
||||
parentMediaId,
|
||||
@@ -1756,6 +1857,12 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
Map<String, dynamic>? extras,
|
||||
]) async {
|
||||
try {
|
||||
// iap-freemium-unlock (Design ADR-4 backstop): voice search resolves a
|
||||
// station and switches to it — a browse/switch action, blocked for
|
||||
// free tier just like `playFromMediaId`/`skipToNext-Previous`.
|
||||
if (debeBloquearCambioDeEmisora(premium: await esPremiumPersistido())) {
|
||||
return;
|
||||
}
|
||||
final fuente = _fuenteNavegacionGlobal;
|
||||
if (fuente == null) return;
|
||||
final candidatas = <Emisora>[
|
||||
@@ -1779,6 +1886,16 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
Map<String, dynamic>? extras,
|
||||
]) async {
|
||||
try {
|
||||
// iap-freemium-unlock (Design ADR-4 backstop): the mandatory backstop
|
||||
// against a head-unit's CACHED browse tree — `getChildren` alone
|
||||
// cannot stop a stale `emisora:<uuid>`/`pista:`/`eq_preset:` tap from
|
||||
// a tree fetched before a downgrade (or from another device). Checked
|
||||
// BEFORE every branch below, including local tracks and the
|
||||
// equalizer (android-auto-media spec "Free-Tier Browse Never Leaks
|
||||
// Real Content (Authoritative Backstop)").
|
||||
if (debeBloquearCambioDeEmisora(premium: await esPremiumPersistido())) {
|
||||
return;
|
||||
}
|
||||
// Local-track playback (Design "Local Track Playback Reuses Existing
|
||||
// Pipeline", Spec "User selects a local track"): FIRST branch,
|
||||
// unconditional `return` — a `pista:` id never falls through to the
|
||||
|
||||
Reference in New Issue
Block a user