Files
purchases-ios/Sources/Checkpoints/CheckpointWorkflowResolver.swift
T
Facundo MenzellaandClaude Opus 5 1064faafd8 feat(checkpoints): Support headless offering checkpoints (#7405)
* feat(checkpoints): Support headless offering checkpoints

Port of purchases-android #3951. When a matched checkpoint rule's workflow
is a single terminal `offering` step, resolve it to that offering and hand
it to the app instead of presenting anything.

The workflow body is now read before the offering mapping, since its shape
decides what else the rule needs. An unservable rule still resolves to
`configurationUnavailable` rather than falling through to a later rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FPNip3cub65pNgGuNbU8pf

* refactor(checkpoints): Realign headless offerings with android 1c579c7

Follows purchases-android#3951 after review there settled its shape:

- rename the resolution cases to matchedWorkflow / matchedOffering
- route on the initial step's type instead of "any offering step", and
  reject a UI workflow that contains one
- drop the screen / trigger / trigger-action guards, an offering step
  renders nothing so anything else it carries is ignored

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FPNip3cub65pNgGuNbU8pf

* fix(checkpoints): Don't prewarm assets for headless offering checkpoints

Prewarming takes its fonts from ui_config rather than from the workflow's
screens, so a screenless offering workflow was still downloading every
downloadable app font on a path that renders nothing. Splits the
non-prewarming read out of getWorkflow and prewarms only on the branch
that goes on to present, matching purchases-android.

Also folds the duplicated offerings lookup into one helper, flattens the
step-type branching, and reuses String.isNotEmpty (which trims newlines
too, unlike the hand-rolled check it replaces).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FPNip3cub65pNgGuNbU8pf

* test(checkpoints): Skip prewarming assertions below iOS 15

scheduleAssetPrewarming is behind an availability guard, so on the older
OS versions CI runs the positive assertion would fail and the negative
one would pass for the wrong reason. Matches the existing skip in
WorkflowManagerTests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FPNip3cub65pNgGuNbU8pf

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 14:16:29 +02:00

306 lines
12 KiB
Swift

//
// Copyright RevenueCat Inc. All Rights Reserved.
//
// Licensed under the MIT License (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://opensource.org/licenses/MIT
//
// CheckpointWorkflowResolver.swift
//
// Created by Rick van der Linden.
//
import Foundation
/// The result of resolving a checkpoint against RevenueCat configuration.
@_spi(Internal) public enum CheckpointResolution {
/// A workflow was selected for the checkpoint.
case matchedWorkflow(ResolvedCheckpointWorkflow)
/// An offering was selected for the checkpoint, with no RevenueCat-managed UI to present. The app
/// decides whether and how to use it.
case matchedOffering(Offering)
/// No workflow should run for the checkpoint.
case noAction(CheckpointResolutionReason)
}
/// The reason that checkpoint resolution selected no workflow.
@_spi(Internal) public enum CheckpointResolutionReason: Sendable {
/// No targeting rule matched.
case noMatch
/// Checkpoint configuration could not be loaded.
case configurationUnavailable
/// Checkpoints are disabled.
case disabled
/// The checkpoint identifier is not configured.
case unknownCheckpoint
}
/// A workflow resolved from RevenueCat configuration and ready for RevenueCatUI to present.
@_spi(Internal) public final class ResolvedCheckpointWorkflow: @unchecked Sendable {
/// The workflow to render.
public let workflow: PublishedWorkflow
/// UI configuration used to render the workflow.
public let uiConfig: UIConfig
/// The offering referenced by the workflow.
public let offering: Offering
/// All offerings available while executing the workflow.
public let offerings: Offerings
init(workflow: PublishedWorkflow, uiConfig: UIConfig, offering: Offering, offerings: Offerings) {
self.workflow = workflow
self.uiConfig = uiConfig
self.offering = offering
self.offerings = offerings
}
}
/// Resolves a checkpoint to the workflow that should run, or the reason no workflow should run.
protocol CheckpointWorkflowResolver: AnyObject {
func resolve(identifier: String, params: CheckpointParams) async throws -> CheckpointResolution
}
final class DisabledCheckpointWorkflowResolver: CheckpointWorkflowResolver {
func resolve(identifier: String, params: CheckpointParams) async throws -> CheckpointResolution {
return .noAction(.disabled)
}
}
/// Resolves checkpoints through the ordered rules served by the `checkpoint_rules` remote-config topic.
///
/// Audience predicates are not served yet, so each rule temporarily uses a predicate that always matches and the
/// published order remains the effective signal.
///
/// The matched rule's workflow body is read first, because its shape decides what else the rule needs: a
/// workflow whose only step is a terminal `offering` step is handed back to the app as an offering, with
/// nothing presented, while every other workflow keeps resolving its offering through the workflows topic
/// and is presented as before.
///
/// The match is final either way. A matched rule that turns out to be unservable resolves to
/// ``CheckpointResolutionReason/configurationUnavailable`` instead of falling through to a rule this
/// customer wasn't the first choice for.
final class DefaultCheckpointWorkflowResolver: CheckpointWorkflowResolver {
private let checkpointsConfigProvider: CheckpointsConfigProviderType
private let workflowManager: WorkflowManager
private let localRulesEvaluator: LocalRulesEvaluator
private let offeringsProvider: () async throws -> Offerings
init(
checkpointsConfigProvider: CheckpointsConfigProviderType,
workflowManager: WorkflowManager,
localRulesEvaluator: LocalRulesEvaluator,
offeringsProvider: @escaping () async throws -> Offerings
) {
self.checkpointsConfigProvider = checkpointsConfigProvider
self.workflowManager = workflowManager
self.localRulesEvaluator = localRulesEvaluator
self.offeringsProvider = offeringsProvider
}
func resolve(identifier: String, params: CheckpointParams) async throws -> CheckpointResolution {
#if DEBUG
// Temporary CheckpointTester escape hatch. Config-backed resolution has no natural throwing case yet.
if identifier == Self.simulatedErrorCheckpointIdentifier {
throw ErrorUtils.configurationError(
message: "Simulated error: checkpoint workflow not presentable."
)
}
#endif
return try await self.resolveConfiguredWorkflow(identifier: identifier, params: params)
}
private func resolveConfiguredWorkflow(
identifier: String,
params: CheckpointParams
) async throws -> CheckpointResolution {
let rulesSnapshot: CheckpointRulesSnapshot
do {
guard let snapshot = try await self.checkpointsConfigProvider.rules(for: identifier) else {
return .noAction(.unknownCheckpoint)
}
rulesSnapshot = snapshot
} catch let error as CancellationError {
throw error
} catch CheckpointRulesProviderError.remoteConfigDisabled {
return .noAction(.disabled)
} catch {
return .noAction(.configurationUnavailable)
}
let rule: CheckpointRule?
do {
rule = try await self.matchingRule(in: rulesSnapshot.ruleSet.rules, params: params)
} catch let error as CancellationError {
throw error
} catch {
Logger.error("The audiences for checkpoint '\(identifier)' could not be evaluated: \(error)")
return .noAction(.configurationUnavailable)
}
// The offering mapping is resolved per branch now, since only a UI workflow needs it.
guard let rule else { return .noAction(.noMatch) }
let resolution = await self.resolve(rule)
guard self.checkpointsConfigProvider.isCurrent(rulesSnapshot) else {
return .noAction(.configurationUnavailable)
}
return resolution
}
private func matchingRule(
in rules: [CheckpointRule],
params: CheckpointParams
) async throws -> CheckpointRule? {
let audienceRules = rules.map {
CheckpointAudienceRule(checkpointRule: $0)
}
let customVariables = params.customVariables.mapValues(\.dimensionValue)
return try await self.localRulesEvaluator.match(
in: audienceRules,
customVariables: customVariables
)?.checkpointRule
}
private func offeringID(for rule: CheckpointRule) async -> String? {
let offeringIdByWorkflowId = await self.workflowManager.offeringIdByWorkflowId()
guard let offeringID = offeringIdByWorkflowId[rule.workflowId] ?? nil else {
Logger.warn(Strings.remoteConfig.checkpointWorkflowRuleSkipped(
workflowID: rule.workflowId,
reason: "no offering identifier is configured"
))
return nil
}
return offeringID
}
private func loadOfferings() async -> Offerings? {
do {
return try await self.offeringsProvider()
} catch {
Logger.error(error.localizedDescription)
return nil
}
}
private func resolve(_ rule: CheckpointRule) async -> CheckpointResolution {
// Deliberately the non-prewarming read: an offering workflow renders nothing, and prewarming takes
// its fonts from `uiConfig` rather than from the workflow's screens, so it isn't free here.
let workflowData: WorkflowDataResult
do {
workflowData = try await self.workflowManager.workflowData(workflowId: rule.workflowId)
} catch {
return Self.unservable(rule, reason: error.localizedDescription)
}
let workflow = workflowData.workflow
guard let initialStep = workflow.steps[workflow.initialStepId] else {
return Self.unservable(rule, reason: "its initial step was not found")
}
if initialStep.type == Self.offeringStepType {
guard workflow.steps.count == 1 else {
return Self.unservable(rule, reason: "an offering step cannot be mixed with other steps")
}
return await self.resolveOffering(rule, step: initialStep)
}
if workflow.steps.values.contains(where: { $0.type == Self.offeringStepType }) {
return Self.unservable(rule, reason: "a UI workflow cannot contain offering steps")
}
return await self.resolveWorkflow(rule, workflowData: workflowData)
}
/// Serves a workflow whose only step is a terminal `offering` step as an offering the app owns.
///
/// Only the offering identifier is validated. Anything else the step happens to carry is ignored rather
/// than treated as unservable, since a step of this kind renders nothing.
private func resolveOffering(_ rule: CheckpointRule, step: WorkflowStep) async -> CheckpointResolution {
guard case let .string(offeringID)? = step.paramValues[Self.offeringIdentifierParam],
offeringID.isNotEmpty else {
return Self.unservable(rule, reason: "the offering step has no valid offering identifier")
}
guard let match = await self.offering(identifier: offeringID, for: rule) else {
return .noAction(.configurationUnavailable)
}
return .matchedOffering(match.offering)
}
private func resolveWorkflow(
_ rule: CheckpointRule,
workflowData: WorkflowDataResult
) async -> CheckpointResolution {
guard let offeringID = await self.offeringID(for: rule),
let match = await self.offering(identifier: offeringID, for: rule) else {
return .noAction(.configurationUnavailable)
}
self.workflowManager.scheduleAssetPrewarming(for: workflowData)
return .matchedWorkflow(
ResolvedCheckpointWorkflow(
workflow: workflowData.workflow,
uiConfig: workflowData.uiConfig,
offering: match.offering,
offerings: match.offerings
)
)
}
/// Loads the offerings and picks `identifier` out of them, logging why the rule can't be served when it
/// isn't there. Both resolution paths need the offering and the container it came from.
private func offering(
identifier: String,
for rule: CheckpointRule
) async -> (offering: Offering, offerings: Offerings)? {
guard let offerings = await self.loadOfferings() else { return nil }
guard let offering = offerings.offering(identifier: identifier) else {
Self.unservable(rule, reason: "offering '\(identifier)' is unavailable")
return nil
}
return (offering, offerings)
}
@discardableResult
private static func unservable(_ rule: CheckpointRule, reason: String) -> CheckpointResolution {
Logger.warn(Strings.remoteConfig.checkpointWorkflowRuleSkipped(
workflowID: rule.workflowId,
reason: reason
))
return .noAction(.configurationUnavailable)
}
private static let offeringStepType = "offering"
private static let offeringIdentifierParam = "offering_identifier"
#if DEBUG
private static let simulatedErrorCheckpointIdentifier = "error_checkpoint"
#endif
private struct CheckpointAudienceRule: LocalRule {
let checkpointRule: CheckpointRule
/// Audience predicates are not served yet, so backend order remains the effective selection signal.
let predicate = "true"
}
}