mirror of
https://github.com/RevenueCat/purchases-ios
synced 2026-11-09 22:35:56 +05:45
* other(samplecat): add a Checkpoints tab with 4 samples Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * other: delete CheckpointTester Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * other: point agents to SampleCat for integration examples Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * other(samplecat): use result.hasEntitlement in checkpoint callbacks Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * other(ci): add a SampleCat smoke build with Tuist (#8163) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
417 lines
16 KiB
Swift
417 lines
16 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.
|
|
//
|
|
|
|
// swiftlint:disable file_length
|
|
|
|
import Foundation
|
|
|
|
// swiftlint:disable file_length
|
|
|
|
/// 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. `traceID` identifies the hit, and the offering's packages carry it too.
|
|
case matchedOffering(Offering, traceID: String)
|
|
/// An ad was selected for the checkpoint, with no RevenueCat-managed UI to present. A registered
|
|
/// ad presenter decides how to show it.
|
|
case matchedAd(ResolvedAdStep)
|
|
/// 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
|
|
/// The checkpoint identifier is not configured.
|
|
case unknownCheckpoint
|
|
|
|
}
|
|
|
|
/// A ``CheckpointResolution`` together with the rule that produced it. The rule id only attributes the hit
|
|
/// event, so it travels here rather than on ``CheckpointResolution``, which RevenueCatUI consumes.
|
|
struct ResolvedCheckpoint {
|
|
|
|
let resolution: CheckpointResolution
|
|
let checkpointRuleID: String?
|
|
/// The matched workflow's or offering's trace id, so the hit and its purchases join on it. Fresh otherwise.
|
|
let traceID: String
|
|
|
|
init(_ resolution: CheckpointResolution, checkpointRuleID: String? = nil) {
|
|
self.resolution = resolution
|
|
self.checkpointRuleID = checkpointRuleID
|
|
switch resolution {
|
|
case let .matchedWorkflow(workflow):
|
|
self.traceID = workflow.traceId
|
|
case let .matchedOffering(_, traceID):
|
|
self.traceID = traceID
|
|
case .matchedAd, .noAction:
|
|
self.traceID = UUID().uuidString
|
|
}
|
|
}
|
|
|
|
init(_ resolution: CheckpointResolution, checkpointRuleID: String?, traceID: String) {
|
|
self.resolution = resolution
|
|
self.checkpointRuleID = checkpointRuleID
|
|
self.traceID = traceID
|
|
}
|
|
|
|
/// Reports `rule` only when it was actually served.
|
|
init(_ resolution: CheckpointResolution, servedBy rule: CheckpointRule, traceID: String) {
|
|
switch resolution {
|
|
case .matchedWorkflow, .matchedOffering, .matchedAd:
|
|
self.init(resolution, checkpointRuleID: rule.id, traceID: traceID)
|
|
|
|
case .noAction:
|
|
self.init(resolution, checkpointRuleID: nil, traceID: traceID)
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
/// 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 -> ResolvedCheckpoint
|
|
|
|
}
|
|
|
|
final class DisabledCheckpointWorkflowResolver: CheckpointWorkflowResolver {
|
|
|
|
func resolve(identifier: String, params: CheckpointParams) async throws -> ResolvedCheckpoint {
|
|
return .init(.noAction(.configurationUnavailable))
|
|
}
|
|
|
|
}
|
|
|
|
/// Resolves checkpoints through the ordered rules served by the `checkpoint_rules` remote-config topic, taking
|
|
/// the first rule whose audience the customer matches.
|
|
///
|
|
/// 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 fetches the complete offerings bundle and resolves each
|
|
/// screen step only when it is reached.
|
|
///
|
|
/// 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 audiencesConfigProvider: AudiencesConfigProviderType
|
|
private let localRulesEvaluator: LocalRulesEvaluator
|
|
private let workflowManager: WorkflowManager
|
|
private let offeringsProvider: () async throws -> Offerings
|
|
private let trackWorkflowEvent: (WorkflowEvent) async -> Void
|
|
|
|
init(
|
|
checkpointsConfigProvider: CheckpointsConfigProviderType,
|
|
audiencesConfigProvider: AudiencesConfigProviderType,
|
|
localRulesEvaluator: LocalRulesEvaluator,
|
|
workflowManager: WorkflowManager,
|
|
offeringsProvider: @escaping () async throws -> Offerings,
|
|
trackWorkflowEvent: @escaping (WorkflowEvent) async -> Void
|
|
) {
|
|
self.checkpointsConfigProvider = checkpointsConfigProvider
|
|
self.audiencesConfigProvider = audiencesConfigProvider
|
|
self.localRulesEvaluator = localRulesEvaluator
|
|
self.workflowManager = workflowManager
|
|
self.offeringsProvider = offeringsProvider
|
|
self.trackWorkflowEvent = trackWorkflowEvent
|
|
}
|
|
|
|
func resolve(identifier: String, params: CheckpointParams) async throws -> ResolvedCheckpoint {
|
|
#if DEBUG
|
|
// Debug-only simulated error for testers. Config-backed resolution has no natural throwing case yet.
|
|
if identifier == Self.simulatedErrorCheckpointIdentifier {
|
|
throw ErrorUtils.configurationError(
|
|
message: "Simulated error: checkpoint workflow not presentable."
|
|
)
|
|
}
|
|
#endif
|
|
|
|
// One id per hit, shared by every event it produces, so a retried attempt keeps it.
|
|
let traceID = UUID().uuidString
|
|
|
|
if let resolved = try await self.attemptResolveConfiguredWorkflow(
|
|
identifier: identifier,
|
|
params: params,
|
|
traceID: traceID
|
|
) {
|
|
return resolved
|
|
}
|
|
Logger.verbose(Strings.checkpoints.resolutionRetry(identifier: identifier))
|
|
|
|
// A `nil` attempt means its configuration became stale while resolving.
|
|
// Retry once against the latest generation before treating repeated staleness as unavailable.
|
|
if let resolved = try await self.attemptResolveConfiguredWorkflow(
|
|
identifier: identifier,
|
|
params: params,
|
|
traceID: traceID
|
|
) {
|
|
return resolved
|
|
}
|
|
Logger.error(Strings.checkpoints.resolutionRepeatedlyStale(identifier: identifier))
|
|
return .init(.noAction(.configurationUnavailable))
|
|
}
|
|
|
|
// A resolution attempt has several distinct terminal states plus the stale sentinel (`nil`).
|
|
// swiftlint:disable:next cyclomatic_complexity function_body_length
|
|
private func attemptResolveConfiguredWorkflow(
|
|
identifier: String,
|
|
params: CheckpointParams,
|
|
traceID: String
|
|
) async throws -> ResolvedCheckpoint? {
|
|
let rulesSnapshot: CheckpointRulesSnapshot
|
|
do {
|
|
guard let snapshot = try await self.checkpointsConfigProvider.rules(for: identifier) else {
|
|
Logger.info(Strings.checkpoints.unknownCheckpoint(identifier: identifier))
|
|
return .init(.noAction(.unknownCheckpoint))
|
|
}
|
|
rulesSnapshot = snapshot
|
|
} catch let error as CancellationError {
|
|
throw error
|
|
} catch CheckpointRulesProviderError.stale {
|
|
return nil
|
|
} catch {
|
|
return .init(.noAction(.configurationUnavailable))
|
|
}
|
|
|
|
guard self.checkpointsConfigProvider.isCurrent(rulesSnapshot) else { return nil }
|
|
guard !rulesSnapshot.ruleSet.rules.isEmpty else {
|
|
return .init(.noAction(.noMatch))
|
|
}
|
|
|
|
let audienceConfiguration: AudienceConfigurationSnapshot
|
|
do {
|
|
guard let configuration = try await self.audiencesConfigProvider.configuration() else {
|
|
guard self.checkpointsConfigProvider.isCurrent(rulesSnapshot) else { return nil }
|
|
return .init(.noAction(.configurationUnavailable))
|
|
}
|
|
audienceConfiguration = configuration
|
|
} catch let error as CancellationError {
|
|
throw error
|
|
} catch {
|
|
guard self.checkpointsConfigProvider.isCurrent(rulesSnapshot) else { return nil }
|
|
Logger.error(Strings.checkpoints.audiencesNotEvaluated(
|
|
checkpointID: identifier,
|
|
reason: "\(error)"
|
|
))
|
|
return .init(.noAction(.configurationUnavailable))
|
|
}
|
|
|
|
guard self.isCurrent(rulesSnapshot, audienceConfiguration) else {
|
|
return nil
|
|
}
|
|
|
|
let ruleEvaluation = try await self.evaluateRules(
|
|
for: identifier,
|
|
in: rulesSnapshot.ruleSet.rules,
|
|
params: params,
|
|
audienceConfiguration: audienceConfiguration
|
|
)
|
|
guard self.isCurrent(rulesSnapshot, audienceConfiguration) else {
|
|
return nil
|
|
}
|
|
|
|
let rule: CheckpointRule?
|
|
switch ruleEvaluation {
|
|
case let .completed(matchedRule):
|
|
rule = matchedRule
|
|
case let .unavailable(error):
|
|
// Only return `noMatch` when audience evaluation succeeds and no rule matches. If evaluation fails,
|
|
// the SDK can't determine whether the app user matches, so treat the checkpoint configuration as
|
|
// unavailable.
|
|
Logger.error(Strings.checkpoints.audiencesNotEvaluated(
|
|
checkpointID: identifier,
|
|
reason: "\(error)"
|
|
))
|
|
return .init(.noAction(.configurationUnavailable))
|
|
}
|
|
|
|
// The offering mapping is resolved per branch now, since only a UI workflow needs it.
|
|
guard let rule else { return .init(.noAction(.noMatch)) }
|
|
|
|
let (resolution, stepEvents) = try await self.resolve(
|
|
rule,
|
|
checkpointIdentifier: identifier,
|
|
traceID: traceID
|
|
)
|
|
guard self.isCurrent(rulesSnapshot, audienceConfiguration) else {
|
|
return nil
|
|
}
|
|
|
|
// Held back until the result is known to be current, so a stale attempt that gets retried emits nothing.
|
|
for event in stepEvents {
|
|
await self.trackWorkflowEvent(event)
|
|
}
|
|
|
|
return .init(resolution, servedBy: rule, traceID: traceID)
|
|
}
|
|
|
|
private func isCurrent(
|
|
_ rulesSnapshot: CheckpointRulesSnapshot,
|
|
_ audienceConfiguration: AudienceConfigurationSnapshot
|
|
) -> Bool {
|
|
return rulesSnapshot.configGeneration == audienceConfiguration.configGeneration
|
|
&& self.checkpointsConfigProvider.isCurrent(rulesSnapshot)
|
|
&& self.audiencesConfigProvider.isCurrent(audienceConfiguration)
|
|
}
|
|
|
|
private func evaluateRules(
|
|
for identifier: String,
|
|
in rules: [CheckpointRule],
|
|
params: CheckpointParams,
|
|
audienceConfiguration: AudienceConfigurationSnapshot
|
|
) async throws -> AudienceRuleEvaluation {
|
|
do {
|
|
return .completed(try await self.matchingRule(
|
|
for: identifier,
|
|
in: rules,
|
|
params: params,
|
|
audienceConfiguration: audienceConfiguration
|
|
))
|
|
} catch let error as CancellationError {
|
|
throw error
|
|
} catch {
|
|
return .unavailable(error)
|
|
}
|
|
}
|
|
|
|
/// Walks the served rules in priority order and returns the first one whose audience matches.
|
|
private func matchingRule(
|
|
for identifier: String,
|
|
in rules: [CheckpointRule],
|
|
params: CheckpointParams,
|
|
audienceConfiguration: AudienceConfigurationSnapshot
|
|
) async throws -> CheckpointRule? {
|
|
return try await self.localRulesEvaluator.match(
|
|
in: rules,
|
|
// Already filtered to valid keys by `DimensionResolver`, which exposes them under `custom.*`.
|
|
customVariables: params.customVariables.mapValues(\.dimensionValue),
|
|
logPrefix: "[Checkpoint '\(identifier)'] "
|
|
) { rule in
|
|
guard let audience = audienceConfiguration.audiences[rule.audienceId] else {
|
|
throw AudienceUnavailableError(audienceID: rule.audienceId)
|
|
}
|
|
|
|
return audience.rules
|
|
}
|
|
}
|
|
|
|
func loadOfferings() async -> Offerings? {
|
|
do {
|
|
return try await self.offeringsProvider()
|
|
} catch {
|
|
Logger.error(error.localizedDescription)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private func resolve(
|
|
_ rule: CheckpointRule,
|
|
checkpointIdentifier: String,
|
|
traceID: String
|
|
) async throws -> (resolution: CheckpointResolution, stepEvents: [WorkflowEvent]) {
|
|
// 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 let error as CancellationError {
|
|
throw error
|
|
} 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 let step = workflow.steps.values.first(where: { $0.gating == .invalid }) {
|
|
return (Self.unservable(rule, reason: "step '\(step.id)' has invalid gating"), [])
|
|
}
|
|
|
|
if initialStep.isOfferingStep {
|
|
return await self.resolveOfferingStep(
|
|
rule, step: initialStep, in: workflowData,
|
|
checkpointIdentifier: checkpointIdentifier, traceID: traceID
|
|
)
|
|
}
|
|
|
|
if initialStep.type == Self.adStepType {
|
|
return await self.resolveAdStep(rule, step: initialStep, in: workflowData, traceID: traceID)
|
|
}
|
|
|
|
if workflow.steps.values.contains(where: \.isOfferingStep) {
|
|
return (Self.unservable(rule, reason: "a UI workflow cannot contain offering steps"), [])
|
|
}
|
|
|
|
if workflow.steps.values.contains(where: { $0.type == Self.adStepType }) {
|
|
return (Self.unservable(rule, reason: "a UI workflow cannot contain ad steps"), [])
|
|
}
|
|
|
|
return (await self.resolveWorkflow(workflowData: workflowData, traceID: traceID), [])
|
|
}
|
|
|
|
private func resolveWorkflow(workflowData: WorkflowDataResult, traceID: String) async -> CheckpointResolution {
|
|
guard let offerings = await self.loadOfferings() else {
|
|
return .noAction(.configurationUnavailable)
|
|
}
|
|
|
|
self.workflowManager.scheduleAssetPrewarming(for: workflowData)
|
|
|
|
return .matchedWorkflow(
|
|
ResolvedCheckpointWorkflow(
|
|
workflow: workflowData.workflow,
|
|
uiConfig: workflowData.uiConfig,
|
|
offerings: offerings,
|
|
workflowBlobRef: workflowData.workflowBlobRef,
|
|
traceId: traceID
|
|
)
|
|
)
|
|
}
|
|
|
|
@discardableResult
|
|
static func unservable(_ rule: CheckpointRule, reason: String) -> CheckpointResolution {
|
|
Logger.warn(Strings.checkpoints.workflowRuleSkipped(
|
|
workflowID: rule.workflowId,
|
|
reason: reason
|
|
))
|
|
return .noAction(.configurationUnavailable)
|
|
}
|
|
|
|
#if DEBUG
|
|
private static let simulatedErrorCheckpointIdentifier = "f0rc3_3rr0r"
|
|
#endif
|
|
}
|
|
/// An audience referenced by a checkpoint rule that the SDK couldn't read, so the rule can't be evaluated.
|
|
private struct AudienceUnavailableError: Error, CustomStringConvertible {
|
|
let audienceID: String
|
|
var description: String {
|
|
return "audience '\(self.audienceID)' could not be read"
|
|
}
|
|
}
|
|
private enum AudienceRuleEvaluation {
|
|
case completed(CheckpointRule?)
|
|
case unavailable(any Error)
|
|
}
|