Files
pluriwave/openspec/changes/iap-freemium-unlock/verify-report.md
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

20 KiB

schema: gentle-ai.verify-result/v1
evidence_revision: sha256:2c382e1b0ea0ead93ebb25ce741be99bc6005c20
verdict: fail
blockers: 2
critical_findings: 2
requirements: 20/20
scenarios: 39/39
test_command: flutter test
test_exit_code: 1
test_output_hash: sha256:3b2a1fcdb1436e77a8a883923ebeb01f7ebc675602162c38c1ca2b42a5acb0c1
build_command: flutter analyze
build_exit_code: 1
build_output_hash: sha256:cb2b64838a0c89a135b8a1b9bda36f57e6060c244129554060b00f6a7f5bcbd6

Verification Report

Change: iap-freemium-unlock Branch/Commit: feat/iap-freemium-unlock, single commit 2c382e1 Version: N/A (no versioned spec revisions) Mode: Strict TDD

Completeness

Metric Value
Tasks total 27
Tasks complete (checked) 26
Tasks incomplete (unchecked in tasks.md) 1 (task 3.3)

Discrepancy: openspec/changes/iap-freemium-unlock/tasks.md line 45 shows task 3.3 (GREEN pantalla_alarmas.dart/_EditorAlarmaSheet + pantalla_vacaciones.dart: cap message plus Desbloquear Premium CTA; vacation upsell) as an unchecked box, despite apply-progress.md's own summary table and both Engram apply-progress observations (#2834, #2835) explicitly claiming ALL PHASES COMPLETE (27/27 tasks) and Phase 3 marked complete for 3.1, 3.2 and 3.3. Source inspection confirms the underlying code for 3.3 IS implemented and covered by regression tests (pantalla_alarmas.dart's _abrirEditor cap-check-plus-interstitial wiring, _mostrarLimiteAlarmas snackbar and CTA, and pantalla_vacaciones.dart's paywall-on-block via mostrarHojaPremium) -- this is a tracking and documentation integrity failure, not a missing implementation. Per the verify decision gate (an unchecked task always remains CRITICAL, even when other artifacts are missing or warnings-only), this blocks a clean archive regardless of the underlying code being present.

Build and Tests Execution

Static analysis: flutter analyze -> exit 1, 5 issues (all confirmed pre-existing and unrelated via git blame: 2x deprecated_member_use on onReorder in pantalla_favoritos.dart and its test, predating this change; 1x unused_catch_stack in servicio_audio.dart:1310, blamed to commit 0e18c822 dated 2026-05-21, predating this change; 1x annotate_overrides in estado_radio_test.dart:865). Matches the apply-progress claim exactly. flutter analyze exits 1 whenever any issue including info level is present -- this is expected repository baseline behavior, not a regression.

Tests: FAILING -- 1242 passed / 2 skipped / 1 FAILED (1245 total), full flutter test run completed in about 2 minutes 34 seconds (contrary to apply-progress's claim that a single flutter test full-suite invocation exceeds this environment's command timeout of about 10 minutes -- it did not, in this run).

$ flutter test
...
02:34 +1242 ~2 -1: Some tests failed.

Failing tests:
  C:/Proyectos/pluriwave/test/l10n/arb_anti_copy_test.dart: every non-es value identical to
  the Spanish template is a deliberately allowlisted exception, not an accidental untranslated
  copy [E]
  Expected: empty
    Actual: [
              pt/desbloquearPremium = "Desbloquear Premium",
              pt/restaurarCompras = "Restaurar compras"
            ]
  Found values identical to the Spanish template that are NOT in
  identical_value_allowlist.dart -- this is very likely an untranslated copy-paste...

This directly contradicts the apply-progress claim of full suite green (719+ tests) and all phases green. The failure is a genuine, reproducible regression against a pre-existing guard test (test/l10n/arb_anti_copy_test.dart, not one of this change's own new test files), caused by this change's own new content: 2 of the 4 new localization keys (desbloquearPremium, restaurarCompras) were left byte-identical to the Spanish template for the pt locale and were never added to identical_value_allowlist.dart nor genuinely translated. The apply-progress literal-encoding scan and dart format checks would never have caught this -- only arb_anti_copy_test.dart catches it, and it was never run: the apply-progress's own batched regression run explicitly lists test/estado/, test/servicios/, test/widgets/, test/pantallas/, and 4 top-level files -- test/l10n/ is absent from every batch, so this defect went undetected until this verify pass ran the real full suite.

Coverage: not measured (no --coverage run performed; not requested by the phase gates and project rules prohibit flutter build, and coverage instrumentation was judged non-essential given the full-suite pass/fail evidence already gathered).

Spec Compliance Matrix (by requirement; 20 requirements / 39 scenarios across 4 domains)

Domain Requirement Covering test(s) Result
premium-entitlement One-Time Non-Consumable Purchase estado_entitlement_test.dart (comprar success/cancel/idempotent) COMPLIANT
premium-entitlement Restore Purchases estado_entitlement_test.dart (restaurar found/not-found) COMPLIANT
premium-entitlement Persisted, Fail-Open Entitlement estado_entitlement_test.dart (loads persisted flag; error does not block payer) COMPLIANT
premium-entitlement Headless-Safe Entitlement Read estado_entitlement_test.dart (esPremiumPersistido group, no BuildContext) COMPLIANT
premium-entitlement Instant Unlock Propagation estado_entitlement_test.dart (ChangeNotifier notification count) plus servicio_audio_gating_test.dart (Auto invalidation hook) COMPLIANT
freemium-gating Gated Feature Set (exactly 4) equalizer-zero-refs grep plus alarm/recording/vacation/Auto gating tests COMPLIANT
freemium-gating Alarm Count Cap At 5 estado_alarmas_gating_test.dart (4/5/6, pre-schedule block, edit-at-cap, premium uncapped) COMPLIANT
freemium-gating Alarm Cap UX Never Bare-Jumps To Paywall pantalla_alarmas.dart _mostrarLimiteAlarmas (source-verified; snackbar plus CTA, no direct nav) COMPLIANT (source; no dedicated widget test asserts the exact snackbar text/CTA pair)
freemium-gating Grandfathering Of Existing Content estado_alarmas_gating_test.dart (8 preexisting alarms stay, only the 9th is blocked) COMPLIANT
freemium-gating Recording Start Gated, Management Stays Free estado_grabacion_gating_test.dart (free blocked, premium allowed, compat default) COMPLIANT
freemium-gating Purchase Entry Points At Every Gate Plus Settings source-verified across pantalla_alarmas.dart, pantalla_vacaciones.dart, pantalla_reproductor.dart, pantalla_ajustes.dart COMPLIANT
ad-display Persistent Top Banner, Never Overlapping Content banner_anuncio_superior_test.dart (Column layout, zero-footprint collapse) COMPLIANT
ad-display Interstitial Before Manual Station Add And Before Alarm Add source-verified (pantalla_alarmas.dart _abrirEditor, pantalla_favoritos.dart, ajustes_emisoras_personalizadas.dart) plus servicio_anuncios_test.dart cap logic COMPLIANT
ad-display Interstitial Frequency Cap servicio_anuncios_test.dart (2 per session, 3-minute spacing, failed load does not consume cap) COMPLIANT
ad-display Interstitial Never Stacks With The Alarm-Cap Message source-verified: _abrirEditor returns early on cap-block, before intentarInterstitial is ever called COMPLIANT
ad-display Ads Vanish Immediately On Purchase servicio_anuncios_test.dart (premium never shows) plus banner_anuncio_superior_test.dart (premium never attempts) COMPLIANT
android-auto-media Browsable Media Tree (premium, regression) navegacion_auto_gating_test.dart (premium identical to current tree) plus navegacion_auto_test.dart (updated call sites, premium true) COMPLIANT
android-auto-media Free-Tier Reduced Root Browse navegacion_auto_gating_test.dart (free: same labels, non-blank, never playable; itemPremiumBloqueado non-crash) COMPLIANT
android-auto-media Free-Tier Browse Never Leaks Real Content (Authoritative Backstop) navegacion_auto_gating_test.dart (stale/deep-linked id backstop) plus servicio_audio_gating_test.dart (debeBloquearCambioDeEmisora) plus source-verified in all 5 servicio_audio.dart call sites COMPLIANT
android-auto-media Current-Station Playback Unaffected By Free Tier source-verified: play(), pause(), stop() in servicio_audio.dart contain no entitlement check COMPLIANT

Compliance summary: 20/20 requirements have runtime or source-verified covering evidence. One requirement (Alarm Cap UX) is source-verified but lacks a dedicated widget test asserting the exact snackbar/CTA pair -- downgraded to a WARNING below, not a blocker, since the logic path is simple and exercised transitively by the passing regression suite.

Orchestrator-Flagged Scrutiny Points

  1. Fail-open entitlement default ("() => true" in estado_alarmas.dart:36, estado_grabacion.dart:57) -- VERIFIED: exactly 2 production construction sites exist for these classes (app.dart lines 71-76, EstadoRadio(esPremium: () => context.read().esPremium), threaded internally to EstadoGrabacion at estado_radio.dart:73; app.dart lines 93-96, EstadoAlarmas(esPremium: ...)), both correctly wired, with EstadoEntitlement registered FIRST in the provider list specifically so these context.read calls resolve. The headless Android Auto path (servicio_audio.dart) never constructs EstadoAlarmas/EstadoGrabacion at all -- it calls esPremiumPersistido() directly, a separate, unaffected function. No current production or headless path reaches the fail-open default. See WARNING below for the latent-risk recommendation.

  2. Android Auto gating completeness (ADR-4) -- VERIFIED COMPLIANT: playFromMediaId, playFromSearch, skipToNext, skipToPrevious all call debeBloquearCambioDeEmisora(premium: await esPremiumPersistido()) and no-op when blocked (servicio_audio.dart lines approximately 1601, 1626, 1863, 1896). play(), pause(), stop() contain no such check -- transport of the current station is untouched. getChildren never returns blank for free tier: respuestaBloqueadaPorEntitlement returns exactly one itemPremiumBloqueado() item for any non-root id, and the root itself always resolves through raiz() (never blocked).

  3. notifyChildrenChanged replacement -- VERIFIED FUNCTIONALLY EQUIVALENT: the deprecated static helper is replaced by PluriWaveAudioHandler.subscribeToChildren (a per-parent-id BehaviorSubject overriding the audio_service base class's stream-based extension point) plus notificarHijosCambiaron(id), which pushes a fresh value into that subject. EstadoEntitlement._desbloquear() calls notificarDesbloqueoAuto() on the free-to-premium edge (only when the user was not already premium), which fires the hook registered in registrarHandler() that pushes to the root plus all 4 folder ids. This is audio_service's own documented replacement mechanism for the deprecated helper (the plugin's internal listener subscribes to subscribeToChildren and forwards to the platform's notifyChildrenChanged itself) -- not a workaround. Covered by servicio_audio_gating_test.dart's registrarNotificacionDesbloqueoAuto group.

  4. Deviation #5, crearRangoVacaciones returns bool -- VERIFIED ACCEPTABLE: the method has exactly one failure mode today (entitlement block returns false); there is no other throw/failure path in its body, so a caller cannot currently confuse "blocked by entitlement" with any other failure. pantalla_vacaciones.dart's _guardar checks "if (!creada) mostrarHojaPremium(context)", correctly routing to the paywall. This is a sound simplification given the current single-failure-mode reality, though it is not future-proof if crearRangoVacaciones ever grows a second failure mode (see SUGGESTION below).

  5. Interstitial ordering (cap-check before interstitial) -- VERIFIED COMPLIANT: pantalla_alarmas.dart's _abrirEditor checks estado.puedeCrearAlarma() FIRST; on false it calls _mostrarLimiteAlarmas(context) and returns immediately -- ServicioAnuncios.intentarInterstitial() is only reached on the true branch. A free user at the 5-alarm cap can never see an interstitial followed by a refusal.

  6. Equalizer NOT gated -- VERIFIED COMPLIANT: zero matches for esPremium, EstadoEntitlement, esPremiumPersistido or ServicioAnuncios across estado_ecualizador.dart, servicio_ecualizador.dart, pantalla_ajustes_ecualizador.dart and ecualizador_widget.dart.

  7. Encoding scan -- VERIFIED CLEAN across all 13 app_*.arb files for the mojibake pattern (A-tilde, A-circumflex, a-euro-etc sequences): only the pre-existing, unrelated app_pt.arb "REPETICAO" false positive. The 4 new keys are byte-clean in every locale. Note: this scan does NOT catch the untranslated-copy defect found above -- that is a semantic/content problem, not a mojibake/encoding problem, and is caught by a different test, arb_anti_copy_test.dart.

  8. Test-harness fixes -- VERIFIED LEGITIMATE: diffed all 9 modified harness files against the commit. Every change is a strictly additive provider registration (ChangeNotifierProvider and/or Provider added to each test's widget tree) required because the new gated call sites now read those providers via context.read/context.watch. Zero existing assertions were removed, weakened, or altered in any of the 9 files (navegacion_auto_test.dart's 3 raiz() call sites gained a "premium: true" argument, not a removed assertion).

TDD Compliance

Check Result Details
TDD Evidence reported Yes Full RED/GREEN/REFACTOR table present in apply-progress.md
All tasks have tests Yes 8 new test files map to every pure-logic phase
RED confirmed (tests exist) Yes All 8 new test files verified present on disk with real assertions
GREEN confirmed (tests pass) Partial 7/8 new test files pass fully; none of the 8 NEW files is the failing one (arb_anti_copy_test.dart is pre-existing)
Triangulation adequate Yes Every gated behavior has 3 or more cases (free/premium/edge -- cap boundary, idempotency, stale-id backstop)
Safety Net for modified files Yes estado_alarmas.dart, estado_grabacion.dart, navegacion_auto.dart, servicio_audio.dart all have pre-existing regression suites re-run and green

TDD Compliance: 6/6 checks passed (the one Partial is about the pre-existing, unrelated l10n regression, not this change's own new tests).

Test Layer Distribution

Layer Tests Files Tools
Unit (pure logic) approx 40 estado_entitlement_test.dart, estado_alarmas_gating_test.dart, estado_grabacion_gating_test.dart, servicio_compras_test.dart, servicio_anuncios_test.dart, navegacion_auto_gating_test.dart, servicio_audio_gating_test.dart flutter_test
Widget approx 8 new plus 9 harness files updated banner_anuncio_superior_test.dart plus regression widget suites flutter_test
E2E 0 none not installed
Total (full suite) 1245 1242 pass / 2 skip / 1 fail

Assertion Quality

Audited all 8 new test files (estado_entitlement_test.dart, servicio_compras_test.dart, estado_alarmas_gating_test.dart, estado_grabacion_gating_test.dart, navegacion_auto_gating_test.dart, servicio_audio_gating_test.dart, servicio_anuncios_test.dart, banner_anuncio_superior_test.dart) for banned patterns (tautologies, ghost loops over possibly-empty collections, assertion-free production calls, ratio of mocks to assertions). Loops over hardcoded non-empty literal lists (for example the respuestaBloqueadaPorEntitlement test's loop over a literal id list) do not qualify as ghost loops since the collection is a non-empty compile-time literal, not a runtime query result.

Assertion quality: All assertions verify real behavior -- 0 CRITICAL, 0 WARNING.

Correctness (Static Evidence)

Requirement area Status Notes
Fail-open entitlement default Implemented, no reachable bypass today See WARNING (latent risk)
Android Auto gate choke points Implemented 5 of 5 dispatch methods gated, 3 of 3 transport methods left open
Vacations full gate Implemented bool return, single failure mode, correctly UI-routed
Ad ordering invariants Implemented Cap-check strictly precedes interstitial
Equalizer isolation Implemented Zero cross-references
l10n new keys Partially implemented 2 of 4 pt keys are untranslated copies (see CRITICAL)

Coherence (Design)

Decision Followed? Notes
ADR-1 (versioned prefs key, fail-open) Yes compra_premium_v1, absent key equals free
ADR-2 (sole in_app_purchase call site) Yes ServicioComprasPlayBilling only
ADR-3 (callback-injection, not direct EstadoEntitlement dependency) Yes Mirrors existing emisoraActual pattern
ADR-4 (root labels visible, lock one level down) Yes Documented deviation from the spec's literal root-locking wording, resolved per orchestrator/design.md; regression-safe for premium
ADR-5 (distinct ResultadoGuardarAlarma enum, not overloaded error field) Yes
ADR-6 (interstitial ordering: cap-check then interstitial then editor) Yes Corrected mid-run per apply-progress's own honest disclosure; final state verified correct
notifyChildrenChanged deprecation workaround Yes Uses the plugin's own documented replacement mechanism

Issues Found

CRITICAL:

  1. tasks.md task 3.3 is unchecked on the filesystem despite apply-progress and Engram artifacts claiming full 27/27 completion. Tracking and documentation integrity failure -- blocks a clean archive per the verify decision gate, even though the underlying implementation and tests for 3.3 are genuinely present and passing.
  2. flutter test (full suite, 1245 tests) FAILS: test/l10n/arb_anti_copy_test.dart catches 2 of the 4 new localization keys (desbloquearPremium, restaurarCompras) left byte-identical to the Spanish template for the pt locale -- a genuine untranslated-copy defect introduced by this change, undetected because the apply agent's regression batches never included test/l10n/. Directly contradicts the "full suite green (719+)" claim.

WARNING:

  1. The fail-open entitlement default in EstadoAlarmas/EstadoGrabacion is a latent monetization-bypass risk pattern: no current call site reaches it, but nothing structurally prevents a future one from silently doing so with no test failure to catch it (the default fabricates full premium access rather than failing safe). Recommend a follow-up hardening task: make esPremium a required parameter (forcing every call site, including the approximately 30 pre-existing tests, to be explicit), or flip the default to "() => false" and update the tests that rely on implicit ungated construction.
  2. "Alarm Cap UX Never Bare-Jumps To Paywall" requirement is source-verified but has no dedicated widget test asserting the exact snackbar text plus secondary CTA pair in isolation.

SUGGESTION:

  1. crearRangoVacaciones's bool return (Deviation #5) works today because it has exactly one failure mode. If a second failure mode is ever added (for example a validation error), the caller will not be able to distinguish it from an entitlement block. Consider migrating to a small result enum before that happens, matching the ResultadoGuardarAlarma and ResultadoIniciarGrabacion precedent already established elsewhere in this same change.
  2. "dart format --set-exit-if-changed lib/ test/" currently flags 18 pre-existing files unrelated to this change (confirmed via diff against the Files Changed table) -- pre-existing repository drift, not a regression, but worth a separate cleanup pass.

Verdict

FAIL -- 2 CRITICAL findings block a clean archive: (1) tasks.md task 3.3 tracking discrepancy, and (2) a genuine, reproducible test failure in the full flutter test suite caused by this change's own untranslated Portuguese localization content, which the apply agent's own claims (full suite green, 27/27 tasks) did not disclose. Both are narrow and mechanically fixable (check the box; translate 2 strings or add reviewed allowlist entries) -- recommend routing back to sdd-apply for a small, targeted fix-and-reverify rather than a full re-implementation. All 20 spec requirements are otherwise source/test-verified compliant, and the 6 orchestrator-flagged scrutiny points (fail-open default, Android Auto gating completeness, notifyChildrenChanged replacement, vacations bool gate, interstitial ordering, equalizer isolation) all check out as implemented correctly.