Files
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

331 lines
18 KiB
Markdown

# Spec: iap-freemium-unlock
Combined view of all domain specs for this change. Authoritative per-domain files live under `openspec/changes/iap-freemium-unlock/specs/{domain}/spec.md`.
---
## Domain: premium-entitlement (NEW)
# Premium Entitlement Specification
## Purpose
Track whether the current user holds the permanent, non-consumable premium unlock, and expose that flag to every gated surface (freemium-gating, ad-display, android-auto-media) both from the UI layer and headlessly (Android Auto, registered before `runApp`).
## Requirements
### Requirement: One-Time Non-Consumable Purchase
The system MUST let the user buy a single non-consumable product via `in_app_purchase` from the Settings row or any contextual upsell. On a successful purchase, entitlement MUST flip to premium immediately, app-wide, with no restart required.
#### Scenario: Successful purchase
- GIVEN a free-tier user taps "buy premium" from Settings or a contextual upsell
- WHEN the purchase completes successfully
- THEN entitlement becomes premium immediately, without restarting the app
#### Scenario: Purchase cancelled or failed
- GIVEN a free-tier user starts the purchase flow
- WHEN the user cancels or the purchase fails
- THEN entitlement remains free tier, and no charge or partial state is left behind
#### Scenario: Already-purchased attempt is idempotent
- GIVEN a user already holds premium entitlement
- WHEN they somehow re-trigger the buy flow
- THEN no duplicate charge occurs and entitlement stays premium
### Requirement: Restore Purchases
Settings MUST expose a "restore purchases" action that re-queries Play Billing and unlocks entitlement when a prior purchase is found.
#### Scenario: Restore finds a prior purchase
- GIVEN a reinstall or new device with no local entitlement flag
- WHEN the user taps "restore purchases" and a valid purchase exists on the Play account
- THEN entitlement becomes premium
#### Scenario: Restore finds nothing
- GIVEN a user with no prior purchase
- WHEN they tap "restore purchases"
- THEN the user stays on the free tier with a clear, non-error-looking result (not a crash or ambiguous failure)
### Requirement: Persisted, Fail-Open Entitlement
Entitlement MUST persist locally under a versioned key (e.g. `compra_premium_v1`) and MUST be readable offline. If an entitlement check cannot complete (no network, Play Billing unreachable), the system MUST fail-open: trust the last persisted flag rather than lock out a payer.
(Previously: no entitlement concept existed.)
#### Scenario: Offline cold start after purchase
- GIVEN a user purchased premium previously
- WHEN they open the app fully offline
- THEN premium entitlement is honored from the persisted flag
#### Scenario: Failed check does not falsely grant premium
- GIVEN a free-tier user with no persisted premium flag
- WHEN an entitlement check fails
- THEN the user remains free tier (fail-open trusts the last flag, it does not invent one)
### Requirement: Headless-Safe Entitlement Read
Entitlement MUST be resolvable via a prefs-lazy fallback (no `BuildContext`/`Provider` dependency), for callers such as `PluriWaveAudioHandler` that register before the widget tree exists.
#### Scenario: Android Auto cold start
- GIVEN the audio handler is constructed before `runApp`
- WHEN it needs to know the current entitlement to build the browse tree
- THEN it resolves entitlement via the prefs-lazy path without requiring a `Provider`
### Requirement: Instant Unlock Propagation
A successful purchase or restore MUST notify all listeners (top banner, gated screens, cached Android Auto entitlement) immediately, without an app restart.
#### Scenario: Banner disappears immediately on purchase
- GIVEN the ad banner is visible when the user completes a purchase
- WHEN the purchase confirms
- THEN the banner disappears immediately, with no restart
---
## Domain: freemium-gating (NEW)
# Freemium Gating Specification
## Purpose
Define which features require premium entitlement, the free-tier alarm cap, grandfathering of existing content, and the non-punitive UX for hitting a limit. The equalizer on the phone is explicitly out of scope — it MUST stay free.
## Requirements
### Requirement: Gated Feature Set (Exactly 4)
The system MUST require premium entitlement for exactly: (1) creating alarm vacations, (2) starting a new station recording, (3) creating an alarm beyond the 5-alarm cap, and (4) full Android Auto browsing (see `android-auto-media`). The phone equalizer MUST NOT be gated under any circumstance.
#### Scenario: Free user uses the phone equalizer
- GIVEN a free-tier user
- WHEN they open and use the equalizer screen on the phone
- THEN it works fully, with no entitlement check and no upsell
#### Scenario: Free user attempts a gated action
- GIVEN a free-tier user
- WHEN they tap "add vacation range" or "start recording"
- THEN they see the paywall/upsell instead of the action completing
### Requirement: Alarm Count Cap At 5 (Free Tier)
`EstadoAlarmas.guardarAlarma` MUST count all alarms, enabled or not, and MUST reject creating a 6th alarm for a free-tier user via a distinct "limit reached" signal, separate from the existing `_error` field used for native scheduling failures.
#### Scenario: 6th alarm creation is blocked
- GIVEN a free-tier user already has 5 alarms (any enabled state)
- WHEN they attempt to create a 6th
- THEN `guardarAlarma` rejects it via the distinct limit signal, and no native scheduling is attempted
#### Scenario: Editing an existing alarm is unaffected
- GIVEN a free-tier user has exactly 5 alarms
- WHEN they edit one of those 5 (not create a new one)
- THEN the edit succeeds normally
#### Scenario: Premium user has no cap
- GIVEN a premium user
- WHEN they create a 6th or later alarm
- THEN it succeeds with no limit check
### Requirement: Alarm Cap UX Never Bare-Jumps To Paywall
Hitting the alarm cap MUST show an explanatory message with a secondary "unlock" action; it MUST NOT navigate directly to the paywall as the sole response to the attempt.
#### Scenario: Cap message with secondary action
- GIVEN a free-tier user hits the 5-alarm cap
- WHEN the limit signal is raised
- THEN the UI shows an explanatory message (e.g. "Has alcanzado el límite de 5 alarmas gratuitas") with a secondary button (e.g. "Desbloquear Premium")
- AND only tapping that secondary button navigates to the paywall
### Requirement: Grandfathering Of Existing Content
Alarms, vacations, and recordings created before the gate existed, or already exceeding the cap, MUST remain visible, usable, and editable-in-place. Only NEW creation past a limit or gate is blocked.
(Previously: no cap or gate existed, so this distinction did not apply.)
#### Scenario: Pre-existing alarms above the cap keep working
- GIVEN a device already has 7 alarms before this change ships
- WHEN the free-tier gate is active
- THEN all 7 alarms keep ringing and can be toggled/edited, and only a new 8th creation is blocked
### Requirement: Recording Start Gated, Management Stays Free
`EstadoGrabacion.iniciar` MUST require premium entitlement. Screens that view, play, or delete already-existing recordings MUST remain accessible regardless of entitlement.
#### Scenario: Free user starts a new recording
- GIVEN a free-tier user
- WHEN they tap the record action
- THEN they see the paywall instead of recording starting
#### Scenario: Free user manages existing recordings
- GIVEN a free-tier user with previously recorded files
- WHEN they open the recordings list
- THEN they can view, play, and delete those recordings normally
### Requirement: Purchase Entry Points At Every Gate Plus Settings
Every gated entry point MUST show a contextual upsell. Settings MUST additionally expose a persistent purchase/restore row.
#### Scenario: Contextual upsell at a gate
- GIVEN a free-tier user reaches any of the 4 gated entry points
- WHEN the gate blocks the action
- THEN a contextual purchase CTA is shown at that point
#### Scenario: Settings always shows a premium row
- GIVEN any user opens Settings
- WHEN the screen renders
- THEN it shows either a "buy premium" row (free tier) or a "premium active" state with restore access (premium tier)
---
## Domain: ad-display (NEW)
# Ad Display Specification
## Purpose
Define where and when `google_mobile_ads` renders for free-tier users only: a persistent top banner, plus a capped interstitial before two specific add actions. Ads MUST be entirely absent for premium users.
## Requirements
### Requirement: Persistent Top Banner, Never Overlapping Content
Free-tier users MUST see a persistent banner anchored at the TOP of the app (not bottom), laid out so it reserves its own space and never overlaps, covers, or displaces existing UI on any of the 5 tabs. Premium users MUST see no banner and no reserved space for it.
#### Scenario: Free user on any tab
- GIVEN a free-tier user
- WHEN they view any of the 5 tabs
- THEN the top banner is visible and all existing content remains fully visible and reachable, none hidden behind it
#### Scenario: Premium user
- GIVEN a premium user
- WHEN they view any tab
- THEN no banner and no reserved banner space is shown
### Requirement: Interstitial Before Manual Station Add And Before Alarm Add
For free-tier users, a full-screen interstitial MUST show before completing exactly two actions: adding a station manually, and adding an alarm. It MUST fire on the action's natural transition (e.g. on confirm/save), never mid-form, and MUST NOT show for premium users.
#### Scenario: Free user adds a station manually
- GIVEN a free-tier user completes the "add station manually" form
- WHEN they confirm the add
- THEN a full-screen interstitial shows once before/around that transition
#### Scenario: Free user adds an alarm
- GIVEN a free-tier user under the 5-alarm cap completes the alarm-creation form
- WHEN they save the new alarm
- THEN a full-screen interstitial shows once before/around that transition
#### Scenario: Premium user performs either action
- GIVEN a premium user
- WHEN they add a station manually or add an alarm
- THEN no interstitial shows
### Requirement: Interstitial Frequency Cap
The system MUST enforce a session-scoped frequency cap on interstitials so that repeated adds in one session do not chain interstitials back-to-back on every single attempt.
#### Scenario: Rapid consecutive adds in one session
- GIVEN a free-tier user adds several stations or alarms in quick succession within the same session
- WHEN each add completes
- THEN not every single add triggers a fresh interstitial — the frequency cap suppresses some per its configured spacing/count rule
### Requirement: Interstitial Never Stacks With The Alarm-Cap Message
If an alarm-add attempt would trigger both the interstitial and the 5-alarm-cap message in the same tap, the alarm-cap message MUST take precedence and the interstitial MUST be suppressed for that attempt.
#### Scenario: Cap hit and interstitial would-be trigger collide
- GIVEN a free-tier user already has 5 alarms
- WHEN they tap "add" for a 6th alarm
- THEN only the alarm-cap explanatory message appears, and no interstitial is shown for that same tap
### Requirement: Ads Vanish Immediately On Purchase
Both the top banner and interstitial triggers MUST stop immediately upon successful purchase or restore, with no app restart required.
#### Scenario: Mid-session purchase
- GIVEN a free-tier user with the banner visible completes a purchase
- WHEN the purchase confirms
- THEN the banner disappears immediately and subsequent adds trigger no interstitial, without restarting the app
---
## Domain: android-auto-media (MODIFIED)
# Delta for Android Auto Media
## MODIFIED Requirements
### Requirement: Browsable Media Tree
For a user holding premium entitlement, `getChildren` MUST return a browsable tree rooted at `AudioService.browsableRootId`, organized into non-playable folders (Favoritos, Todas las emisoras, Mis emisoras, Ecualizador, and the local-music root) containing playable items. Playable station items SHOULD carry an audio-quality subtitle when known. The `Favoritos` folder additionally MAY contain non-playable favorite-group sub-folders (see "Favorite Group Sub-Folders"); `Todas las emisoras` and `Mis emisoras` remain flat. The `Ecualizador` folder is flat, non-playable, and contains only the 6 fixed EQ preset items (see "EQ Preset Browsable Folder"). The local-music root folder is non-playable and may itself be nested (see "Local Music Browsable Tree"). For a free-tier (non-premium) user, this full tree is NOT exposed; see "Free-Tier Reduced Root Browse" for the entitlement-aware equivalent.
(Previously: root contained exactly 3 folders — Favoritos, Todas las emisoras, Mis emisoras — with no EQ or local-music folder; Favoritos was a flat folder of playable station items only, with no sub-folder nesting; there was no entitlement distinction.)
#### Scenario: Car requests the root (premium)
- GIVEN the user holds premium entitlement and the car head unit connects and requests the root (`AudioService.browsableRootId`)
- WHEN `getChildren` is called with the root id
- THEN it returns five folder `MediaItem`s (Favoritos, Todas las emisoras, Mis emisoras, Ecualizador, and the local-music root), each with `playable: false`
#### Scenario: Car requests a folder with no stations (premium)
- GIVEN the user holds premium entitlement and has zero favorite stations
- WHEN `getChildren` is called with the Favoritos folder id
- THEN it returns an empty list, not an error
#### Scenario: Browse requested before app state is loaded (premium)
- GIVEN the user holds premium entitlement and the audio handler starts cold and station/favorites Provider state has not finished loading
- WHEN `getChildren` is called (root or any folder)
- THEN it returns a valid, possibly empty, list without throwing and without blocking or crashing the service
#### Scenario: Station has known codec and bitrate
- GIVEN a station's `Emisora.codec` and `Emisora.bitrate` are both known (non-null)
- WHEN it is mapped to a playable `MediaItem`
- THEN `displaySubtitle` SHALL contain a human-readable quality hint combining bitrate and codec (e.g. "128 kbps · MP3")
#### Scenario: Station has unknown codec or bitrate
- GIVEN a station's `Emisora.codec` or `Emisora.bitrate` (or both) is null/unknown
- WHEN it is mapped to a playable `MediaItem`
- THEN `displaySubtitle` SHALL omit the quality hint gracefully (no subtitle, or a subtitle with no quality fragment)
- AND the subtitle MUST NOT render literal placeholder text such as "null kbps" or "null · null"
#### Scenario: Ungrouped station appears exactly as before (regression guard)
- GIVEN a station's `Emisora.grupoFavoritosId` equals `GrupoFavoritos.sinAsignarId` (`'sin_asignar'`, the default when no group is assigned), and the browsing user holds premium entitlement
- WHEN the `Favoritos`, `Todas las emisoras`, or `Mis emisoras` folders are browsed
- THEN that station appears as a playable `emisora:<uuid>` item in exactly the same folder(s), position (subject to existing sort rules), title, art, and subtitle as it did before favorite-group folders were introduced
- AND its presence and shape are unaffected by the existence, emptiness, or content of any favorite group
## ADDED Requirements
### Requirement: Free-Tier Reduced Root Browse
For a free-tier (non-premium) user, `getChildren` at the root MUST NOT return the full folder tree. Instead it MUST return a non-blank list whose items each represent one of the normally-browsable folders (Favoritos, Todas las emisoras, Mis emisoras, Ecualizador, local-music root) rendered as a non-playable, explicitly locked item labeled as a premium feature (e.g. title "Función Premium"). A blank or empty root/folder response for a free-tier user is forbidden.
#### Scenario: Free-tier user requests the root
- GIVEN a free-tier (non-premium) user's car head unit requests the root
- WHEN `getChildren` is called with the root id
- THEN it returns non-playable locked items labeled as premium features, one per normally-browsable folder, and never an empty list
#### Scenario: Free-tier user selects a locked item
- GIVEN a free-tier user is shown a locked "Función Premium" item
- WHEN they select it
- THEN no real folder content or station list is returned, and no crash or unhandled exception occurs
### Requirement: Free-Tier Browse Never Leaks Real Content (Authoritative Backstop)
Even if `getChildren` receives a stale or deep-linked folder id that would resolve to real station or local-music content, for a free-tier (non-premium) user it MUST NOT return that real content. This check MUST be enforced at the `getChildren`/`navegacion_auto.dart` choke point itself, independent of which UI path reached it.
#### Scenario: Stale folder id bypass attempt
- GIVEN a free-tier user's car client holds a cached `emisora:<uuid>` or folder id from before downgrade or from another device
- WHEN `getChildren`/`playFromMediaId` is called with that id
- THEN the authoritative entitlement check at the choke point blocks real content or playback from being returned, regardless of the id's validity
### Requirement: Current-Station Playback Unaffected By Free Tier
Regardless of entitlement, transport controls (play/pause/stop) for whatever station is already loaded or playing MUST keep working for a free-tier user in the car. Only browsing/switching to a different station and local music are restricted by the free tier.
#### Scenario: Free-tier user controls the current station
- GIVEN a free-tier user already has a station loaded or playing when connecting to the car
- WHEN they use play/pause/stop from the car head unit
- THEN the command is honored exactly as for a premium user
#### Scenario: Free-tier user cannot switch stations via browse
- GIVEN a free-tier user is currently playing a station
- WHEN they attempt to browse to a different station via the root tree
- THEN they see only the locked "Función Premium" items, not a station list, and cannot switch stations that way