// // 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) }