mirror of
https://github.com/RevenueCat/purchases-ios
synced 2026-11-09 22:35:56 +05:45
* Send fallback_original_step_id and always send blob_ref on workflow events khepri publishes ~f fallback copies of screens for audience-targeted experiments (WFL-626). Workflow events now carry the copy's fallback_original_step_id and send blob_ref whenever the SDK has one, so khepri resolves each step against its exact blob. Experiment params are read from step metadata first, then param_values. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Trim workflow event comments Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Drop legacy blob_ref decoding for queued workflow events Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Revert unrelated Package.resolved change Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Rename experimentParam to experimentValueFromMetadataOrParams Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
207 lines
7.0 KiB
Swift
207 lines
7.0 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
|
|
//
|
|
// FeatureEventsRequest+WorkflowEvent.swift
|
|
|
|
import Foundation
|
|
|
|
/// Type alias to avoid naming conflict inside `FeatureEventsRequest.WorkflowEvent`.
|
|
private typealias StoredWorkflowEvent = WorkflowEvent
|
|
|
|
extension FeatureEventsRequest {
|
|
|
|
/// Khepri-compatible wire format for workflow step lifecycle events.
|
|
struct WorkflowEvent {
|
|
|
|
let type: String
|
|
let id: String
|
|
let version: Int
|
|
let eventName: String
|
|
let timestampMs: UInt64
|
|
let appUserID: String
|
|
let context: Context
|
|
let properties: Properties
|
|
|
|
// swiftlint:disable nesting
|
|
struct Context {
|
|
let locale: String
|
|
}
|
|
|
|
struct Properties {
|
|
let workflowId: String
|
|
let stepId: String
|
|
let traceId: String?
|
|
let fromStepId: String?
|
|
let toStepId: String?
|
|
let entryReason: String?
|
|
let isFirstStep: Bool?
|
|
let isLastStep: Bool?
|
|
let experimentId: String?
|
|
let experimentVariant: String?
|
|
let workflowBlobRef: String?
|
|
let fallbackOriginalStepId: String?
|
|
}
|
|
// swiftlint:enable nesting
|
|
|
|
}
|
|
|
|
}
|
|
|
|
extension FeatureEventsRequest.WorkflowEvent {
|
|
|
|
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
|
|
init?(storedEvent: StoredFeatureEvent) {
|
|
guard storedEvent.feature == .workflows else { return nil }
|
|
|
|
guard let jsonData = storedEvent.encodedEvent.data(using: .utf8) else {
|
|
Logger.error(Strings.paywalls.event_cannot_get_encoded_event)
|
|
return nil
|
|
}
|
|
|
|
do {
|
|
let event = try JSONDecoder.default.decode(StoredWorkflowEvent.self, from: jsonData)
|
|
let wire = Self.wireFields(for: event)
|
|
|
|
self.init(
|
|
type: Self.typeValue,
|
|
id: event.creationData.id.uuidString,
|
|
version: Self.schemaVersion,
|
|
eventName: wire.eventName,
|
|
timestampMs: event.creationData.date.millisecondsSince1970,
|
|
appUserID: storedEvent.userID,
|
|
context: Context(locale: event.data.localeIdentifier),
|
|
properties: Properties(
|
|
workflowId: event.data.workflowId,
|
|
stepId: event.data.stepId,
|
|
traceId: event.data.traceId,
|
|
fromStepId: wire.fromStepId,
|
|
toStepId: wire.toStepId,
|
|
entryReason: wire.entryReason,
|
|
isFirstStep: event.data.isFirstStep,
|
|
isLastStep: event.data.isLastStep,
|
|
experimentId: event.data.experiment?.experimentId,
|
|
experimentVariant: event.data.experiment?.experimentVariant,
|
|
workflowBlobRef: event.data.workflowBlobRef,
|
|
fallbackOriginalStepId: event.data.fallbackOriginalStepId
|
|
)
|
|
)
|
|
} catch {
|
|
Logger.error(Strings.paywalls.event_cannot_deserialize(error))
|
|
return nil
|
|
}
|
|
}
|
|
|
|
/// The event-name and navigation fields that vary per `WorkflowEvent` case. `close` is an
|
|
/// abandonment signal, not a navigation, so it carries no from/to step or entry reason.
|
|
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
|
|
private static func wireFields(for event: StoredWorkflowEvent) -> WireFields {
|
|
switch event {
|
|
case .stepStarted:
|
|
return .init(
|
|
eventName: stepStartedEventName,
|
|
fromStepId: event.data.fromStepId,
|
|
toStepId: nil,
|
|
entryReason: event.data.entryReason
|
|
)
|
|
case .stepCompleted:
|
|
return .init(
|
|
eventName: stepCompletedEventName,
|
|
fromStepId: nil,
|
|
toStepId: event.data.toStepId,
|
|
entryReason: nil
|
|
)
|
|
case .close:
|
|
return .init(eventName: closeEventName, fromStepId: nil, toStepId: nil, entryReason: nil)
|
|
}
|
|
}
|
|
|
|
private struct WireFields {
|
|
let eventName: String
|
|
let fromStepId: String?
|
|
let toStepId: String?
|
|
let entryReason: String?
|
|
}
|
|
|
|
private static let schemaVersion = 1
|
|
private static let typeValue = "workflows"
|
|
private static let stepStartedEventName = "workflow_step_started"
|
|
private static let stepCompletedEventName = "workflow_step_completed"
|
|
private static let closeEventName = "workflow_close"
|
|
|
|
}
|
|
|
|
// MARK: - Encodable
|
|
|
|
extension FeatureEventsRequest.WorkflowEvent: Encodable {
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
|
|
case type
|
|
case id
|
|
case version
|
|
case eventName = "event_name"
|
|
case timestampMs = "timestamp_ms"
|
|
case appUserID = "app_user_id"
|
|
case context
|
|
case properties
|
|
|
|
}
|
|
|
|
}
|
|
|
|
extension FeatureEventsRequest.WorkflowEvent.Context: Encodable {
|
|
|
|
func encode(to encoder: Encoder) throws {
|
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
try container.encode(locale, forKey: .locale)
|
|
}
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case locale
|
|
}
|
|
|
|
}
|
|
|
|
extension FeatureEventsRequest.WorkflowEvent.Properties: Encodable {
|
|
|
|
func encode(to encoder: Encoder) throws {
|
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
try container.encode(workflowId, forKey: .workflowId)
|
|
try container.encode(stepId, forKey: .stepId)
|
|
try container.encodeIfPresent(traceId, forKey: .traceId)
|
|
try container.encodeIfPresent(fromStepId, forKey: .fromStepId)
|
|
try container.encodeIfPresent(toStepId, forKey: .toStepId)
|
|
try container.encodeIfPresent(entryReason, forKey: .entryReason)
|
|
try container.encodeIfPresent(isFirstStep, forKey: .isFirstStep)
|
|
try container.encodeIfPresent(isLastStep, forKey: .isLastStep)
|
|
try container.encodeIfPresent(experimentId, forKey: .experimentId)
|
|
try container.encodeIfPresent(experimentVariant, forKey: .experimentVariant)
|
|
try container.encodeIfPresent(workflowBlobRef, forKey: .workflowBlobRef)
|
|
try container.encodeIfPresent(fallbackOriginalStepId, forKey: .fallbackOriginalStepId)
|
|
}
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
|
|
case workflowId = "workflow_id"
|
|
case stepId = "step_id"
|
|
case traceId = "trace_id"
|
|
case fromStepId = "from_step_id"
|
|
case toStepId = "to_step_id"
|
|
case entryReason = "entry_reason"
|
|
case isFirstStep = "is_first_step"
|
|
case isLastStep = "is_last_step"
|
|
case experimentId = "experiment_id"
|
|
case experimentVariant = "experiment_variant"
|
|
case workflowBlobRef = "blob_ref"
|
|
case fallbackOriginalStepId = "fallback_original_step_id"
|
|
|
|
}
|
|
|
|
}
|