Skip to content

Gate-focused checkpoint() API - #4151

Merged
tonidero merged 10 commits into
mainfrom
toni/checkpoints-gate-api-poc
Sep 15, 2026
Merged

tonidero merged 10 commits into
mainfrom
toni/checkpoints-gate-api-poc

Conversation

@tonidero

@tonidero tonidero commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Description

New Checkpoints API based on gating behind a closure when the user goes through the checkpoint flow, if any:

Purchases.sharedInstance.checkpoint("onboarding_completed", CheckpointParams {}) { result ->
    // result == null: nothing was presented (no rule matched, nothing could be served, or presenting failed)
    // result != null: the user passed the presented flow; result.obtainedEntitlements is what they obtained
    navigateToHome()
}

When the callback fires

CheckpointPassedCallback.onCheckpointPassed is invoked at most once, on the main thread, when the user passes the checkpoint:

  • with null when nothing was presented, whatever the reason.
  • with a FlowResult when the presented flow ended with a purchase or restore, or through a close action.

It is not invoked when the user backs out of the presented flow (system back, or a navigate_back action on a workflow's first step).

Only one checkpoint flow is presented at a time. A checkpoint that resolves to a flow while another flow is already on screen is ignored and its callback is never invoked: the call that presented the flow is the one that reports. A checkpoint that resolves to nothing still passes null meanwhile.


Note

High Risk
Breaking internal checkpoint API and paywall presentation lifecycle changes affect monetization gating, concurrent checkpoint behavior, and entitlement reporting for integrators on the new surface.

Overview
Replaces the suspend awaitCheckpoint / CheckpointResult surface with a callback-based Purchases.checkpoint() API aimed at gating: CheckpointPassedCallback receives FlowResult? (null when nothing was presented; otherwise obtainedEntitlements for what the user gained). The call does not throw—failures and no-match cases are logged and surface as null. The callback is skipped when the user backs out of the flow or when a second checkpoint would present while another flow is already on screen.

Public CheckpointResult and CheckpointPaywallOutcome are removed; presentation outcomes move to internal CheckpointFlowOutcome. Offering resolutions now present a fallback offering paywall instead of returning data for app-owned UI. CheckpointWorkflowPresenter defers tearing down the window until after the app is notified via finishPresentation.

Core seam renames: resolveCheckpointinternalResolveCp, checkpointManagerSlotinternalCpManagerSlot. LocalRulesEvaluator logging is tightened. API testers, checkpointtester / paywall-tester samples, and unit tests are updated for the new contract.

Reviewed by Cursor Bugbot for commit caba0f3. Bugbot is set up for automated code reviews on this repo. Configure here.

@RevenueCat-Danger-Bot

RevenueCat-Danger-Bot commented Sep 1, 2026

Copy link
Copy Markdown
1 Message
📖 This PR changes 883 lines of production Kotlin/Java (limit 300); skipped via skip-pr-lines-changed-check label.

Generated by 🚫 Danger

@tonidero
tonidero force-pushed the toni/checkpoints-gate-api-poc branch from f047b1c to 18d7136 Compare September 1, 2026 14:48
@tonidero
tonidero changed the base branch from main to toni/fail-resolution-on-customer-change September 3, 2026 14:30
@tonidero
tonidero force-pushed the toni/checkpoints-gate-api-poc branch from 18d7136 to 6e4ca05 Compare September 3, 2026 14:44
@tonidero
tonidero force-pushed the toni/checkpoints-gate-api-poc branch from 6e4ca05 to 0167501 Compare September 3, 2026 15:39
@tonidero
tonidero changed the base branch from toni/fail-resolution-on-customer-change to toni/unresolved-variables-no-match September 4, 2026 07:32
@tonidero
tonidero force-pushed the toni/checkpoints-gate-api-poc branch from 0167501 to b9cfeff Compare September 4, 2026 07:32
@tonidero
tonidero changed the base branch from toni/unresolved-variables-no-match to toni/offering-from-initial-step September 4, 2026 08:58
@tonidero
tonidero force-pushed the toni/checkpoints-gate-api-poc branch from b9cfeff to ad2c2c4 Compare September 4, 2026 08:59
@tonidero
tonidero force-pushed the toni/checkpoints-gate-api-poc branch from 8c6fd87 to 877c1af Compare September 4, 2026 10:55
@tonidero
tonidero force-pushed the toni/checkpoints-gate-api-poc branch from 877c1af to e6196e0 Compare September 4, 2026 11:32
@tonidero
tonidero force-pushed the toni/checkpoints-gate-api-poc branch 2 times, most recently from 39b6772 to 22459e6 Compare September 4, 2026 12:03
@tonidero
tonidero force-pushed the toni/checkpoints-gate-api-poc branch 2 times, most recently from 0975842 to e20c109 Compare September 4, 2026 13:16
@tonidero
tonidero force-pushed the toni/checkpoints-gate-api-poc branch from e3b15d7 to c73c72e Compare September 4, 2026 13:40
@tonidero
tonidero changed the base branch from toni/offering-from-initial-step to toni/checkpoint-back-vs-close September 6, 2026 17:16
@tonidero
tonidero force-pushed the toni/checkpoints-gate-api-poc branch from c73c72e to b400df0 Compare September 6, 2026 17:18
@tonidero
tonidero changed the base branch from toni/checkpoint-back-vs-close to toni/checkpoint-evaluated-event September 6, 2026 18:25
@tonidero
tonidero force-pushed the toni/checkpoints-gate-api-poc branch 2 times, most recently from 694af08 to 4df5d65 Compare September 10, 2026 18:19
@tonidero
tonidero force-pushed the toni/checkpoints-gate-api-poc branch from 4df5d65 to cdccbe5 Compare September 11, 2026 07:30

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

import kotlin.coroutines.resume

/**
* The entitlements active before a checkpoint runs, so grants can be told apart from what the user already

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually had it fetch the customer info beforehand 🤔 but now that I think about it this also makes sense and is a bit less complex.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, I try to minimize the requests when hitting a checkpoint, so only getting it from cache.

@tonidero
tonidero force-pushed the toni/checkpoints-gate-api-poc branch from 5d455c6 to fcf039a Compare September 11, 2026 12:01
@tonidero
tonidero removed this pull request from stack #4205 September 11, 2026 13:37
@tonidero
tonidero force-pushed the toni/checkpoints-gate-api-poc branch from fcf039a to e32107a Compare September 11, 2026 13:42
@tonidero
tonidero added this pull request to stack #4223 September 11, 2026 13:42

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

@tonidero
tonidero force-pushed the toni/checkpoints-gate-api-poc branch from e32107a to 28d837c Compare September 11, 2026 14:20

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

tonidero and others added 9 commits September 14, 2026 16:25
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A checkpoint flow used to be taken down before the app was told the user went through, so the host screen showed its pre-callback state for a frame before the app reacted. The presenter now reports first and takes the window down once checkpoint() has invoked the callback (or skipped it for a backed-out flow), so whatever the callback puts on screen is already there when the flow goes away.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…sented

A second checkpoint() call while a flow was on screen came back as nothing presented, so its callback fired with null and the gate let the user through behind the paywall that was still showing. The blocked call now skips its callback entirely; the call that presented the flow is the one that reports. A checkpoint that resolves to no flow still passes null.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit f8c0a57. Configure here.

_state.update { it.copy(running = true, message = null) }
viewModelScope.launch {
val message = try {
val result = Purchases.sharedInstance.awaitCheckpoint(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Back-out leaves screens stuck

Medium Severity

The new checkpoint() callback is intentionally skipped when the user backs out, but these screens set running/waitingFor before the call and only clear it in that callback. System back (or navigate_back on the first step) therefore leaves the UI disabled with no way to retry, restart, or continue. Onboarding still claims a flow outcome must not strand the user mid-flow; hard paywall still claims dismissal leaves the content locked and lets the user try again. The same latch exists on the entitlement gate, custom, soft paywall, and use-case screens.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f8c0a57. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is actually fixed in the follow-up PR: #4155

@rickvdl rickvdl left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this looks great! We're fully aligned now as far as I could see 💪 Just one small difference

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants