diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a311c7eb0..8f9f1daa69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,13 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/superwall/Superwall-iOS/releases) on GitHub. -## 4.15.4 +## 4.16.0 ### Enhancements - Adds support for annual subscriptions that are billed monthly. +- Added `EventTrackingBehavior` enum and `SuperwallOptions.eventTrackingBehavior` property for GDPR-compliant event collection control. Use `.all` (default) to track everything, `.superwallOnly` to suppress user-initiated tracking, trigger fires, and user-attribute updates while keeping internal SDK events, or `.none` to stop all event collection entirely. The behavior can also be changed at runtime via `Superwall.shared.eventTrackingBehavior`. +- Deprecated `SuperwallOptions.isExternalDataCollectionEnabled`. Setting it to `false` now maps to `.superwallOnly`; setting it back to `true` maps to `.all`. ### Fixes diff --git a/Sources/SuperwallKit/Config/Options/EventTrackingBehavior.swift b/Sources/SuperwallKit/Config/Options/EventTrackingBehavior.swift new file mode 100644 index 0000000000..ec769c6b0e --- /dev/null +++ b/Sources/SuperwallKit/Config/Options/EventTrackingBehavior.swift @@ -0,0 +1,41 @@ +// +// EventTrackingBehavior.swift +// +// +// Created by Yusuf Tör on 22/06/2026. +// + +import Foundation + +/// Controls which events are sent to the Superwall servers. +/// +/// Use ``SuperwallOptions/eventTrackingBehavior`` or set ``Superwall/eventTrackingBehavior`` +/// at runtime to change event collection at any time. +/// +/// - `.all`: All events are tracked (default). +/// - `.superwallOnly`: Only internal Superwall events are tracked. User-initiated +/// ``Superwall/track(event:params:)`` calls, trigger fires, and user-attribute updates +/// are suppressed. Equivalent to the deprecated `isExternalDataCollectionEnabled = false`. +/// - `.none`: No events are sent to the Superwall servers. +@objc(SWKEventTrackingBehavior) +public enum EventTrackingBehavior: Int, CustomStringConvertible, Encodable, Sendable { + /// All events are tracked. This is the default. + case all = 0 + + /// Only internal Superwall events are tracked. + /// + /// User-initiated tracking calls, trigger-fire events, and user-attribute + /// updates are suppressed. All other internal SDK events continue to be sent. + case superwallOnly = 1 + + /// No events are sent to the Superwall servers. + case none = 2 + + public var description: String { + switch self { + case .all: return "all" + case .superwallOnly: return "superwallOnly" + case .none: return "none" + } + } +} diff --git a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift index f7d42bbeda..b1ce71703b 100644 --- a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift +++ b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift @@ -297,12 +297,35 @@ public final class SuperwallOptions: NSObject, Encodable { /// - Note: You cannot use ``Superwall/purchase(_:)`` while this is `true`. public var shouldObservePurchases = false + /// Controls which events are sent to the Superwall servers. + /// + /// Defaults to ``EventTrackingBehavior/all``. Set this to ``EventTrackingBehavior/superwallOnly`` + /// to suppress user-initiated tracking, trigger fires, and user-attribute updates, or to + /// ``EventTrackingBehavior/none`` to stop all event collection (e.g. for GDPR compliance). + /// + /// You can also change this at runtime via ``Superwall/eventTrackingBehavior``. + public var eventTrackingBehavior: EventTrackingBehavior = .all + /// Enables the sending of non-Superwall tracked events and properties back to the Superwall servers. /// Defaults to `true`. /// - /// Set this to `false` to stop external data collection. This will not affect - /// your ability to create placements based on properties. - public var isExternalDataCollectionEnabled = true + /// - Warning: Deprecated. Use ``eventTrackingBehavior`` instead. + /// Setting this to `false` maps to ``EventTrackingBehavior/superwallOnly`` unless the current + /// value is already ``EventTrackingBehavior/none``, in which case `.none` is preserved. + /// Setting it back to `true` maps to ``EventTrackingBehavior/all``. + @available(*, deprecated, renamed: "eventTrackingBehavior") + public var isExternalDataCollectionEnabled: Bool { + get { + return eventTrackingBehavior == .all + } + set { + if newValue { + eventTrackingBehavior = .all + } else if eventTrackingBehavior != .none { + eventTrackingBehavior = .superwallOnly + } + } + } /// Sets the device locale identifier to use when evaluating audience filters. /// @@ -374,7 +397,7 @@ public final class SuperwallOptions: NSObject, Encodable { public var logging = Logging() private enum CodingKeys: String, CodingKey { - case isExternalDataCollectionEnabled + case eventTrackingBehavior case localeIdentifier case isGameControllerEnabled case storeKitVersion @@ -409,7 +432,7 @@ public final class SuperwallOptions: NSObject, Encodable { try networkEnvironment.encode(to: encoder) try logging.encode(to: encoder) - try container.encode(isExternalDataCollectionEnabled, forKey: .isExternalDataCollectionEnabled) + try container.encode(eventTrackingBehavior.description, forKey: .eventTrackingBehavior) try container.encode(localeIdentifier, forKey: .localeIdentifier) try container.encode(isGameControllerEnabled, forKey: .isGameControllerEnabled) try container.encode(storeKitVersion.description, forKey: .storeKitVersion) diff --git a/Sources/SuperwallKit/Misc/Constants.swift b/Sources/SuperwallKit/Misc/Constants.swift index e926212ec0..5338fe7359 100644 --- a/Sources/SuperwallKit/Misc/Constants.swift +++ b/Sources/SuperwallKit/Misc/Constants.swift @@ -18,5 +18,5 @@ let sdkVersion = """ */ let sdkVersion = """ -4.15.4 +4.16.0 """ diff --git a/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandler.swift b/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandler.swift index 2b5ce4e72f..c728286698 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandler.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandler.swift @@ -301,6 +301,17 @@ final class PaywallMessageHandler: WebEventDelegate { await passMessageToWebView(base64Templates) } + func passEventTrackingBehaviorToWebView(_ behavior: EventTrackingBehavior) { + let event: [String: Any] = [ + "event_name": "event_tracking_behavior", + "behavior": behavior.description + ] + guard let jsonData = try? JSONSerialization.data(withJSONObject: [event]) else { + return + } + passMessageToWebView(jsonData.base64EncodedString()) + } + private func passMessageToWebView(_ base64String: String) { let messageScript = """ window.paywall.accept64('\(base64String)'); @@ -372,6 +383,9 @@ final class PaywallMessageHandler: WebEventDelegate { paywallInfo: paywallInfo ) await Superwall.shared.track(webViewLoad) + + let behavior = await Superwall.shared.eventTrackingBehavior + await self.passEventTrackingBehaviorToWebView(behavior) } let htmlSubstitutions = paywall.htmlSubstitutions diff --git a/Sources/SuperwallKit/Storage/PlacementsQueue.swift b/Sources/SuperwallKit/Storage/PlacementsQueue.swift index 32befaf33c..f0cdb8e6ee 100644 --- a/Sources/SuperwallKit/Storage/PlacementsQueue.swift +++ b/Sources/SuperwallKit/Storage/PlacementsQueue.swift @@ -1,6 +1,6 @@ // // File.swift -// +// // // Created by brian on 8/16/21. // @@ -16,7 +16,8 @@ actor PlacementsQueue { private var elements: [JSON] = [] private var timer: Timer? private unowned let network: Network - private unowned let configManager: ConfigManager + private var trackingBehavior: EventTrackingBehavior + private let timerInterval: Double @MainActor private var resignActiveObserver: AnyCancellable? @@ -31,7 +32,14 @@ actor PlacementsQueue { configManager: ConfigManager ) { self.network = network - self.configManager = configManager + // Capture synchronously while configManager is guaranteed alive. + self.trackingBehavior = configManager.options.eventTrackingBehavior + switch configManager.options.networkEnvironment { + case .release: + self.timerInterval = 20.0 + default: + self.timerInterval = 1.0 + } Task { [weak self] in await self?.setupTimer() await self?.addObserver() @@ -39,15 +47,8 @@ actor PlacementsQueue { } private func setupTimer() { - let timeInterval: Double - switch configManager.options.networkEnvironment { - case .release: - timeInterval = 20.0 - default: - timeInterval = 1.0 - } let timer = Timer( - timeInterval: timeInterval, + timeInterval: timerInterval, repeats: true ) { [weak self] _ in guard let self = self else { @@ -76,25 +77,41 @@ actor PlacementsQueue { data: JSON, from placement: Trackable ) { - guard externalDataCollectionAllowed(from: placement) else { + guard trackingAllowed(from: placement) else { return } elements.append(data) } - private func externalDataCollectionAllowed(from placement: Trackable) -> Bool { - if Superwall.shared.options.isExternalDataCollectionEnabled { - return true + func setTrackingBehavior(_ behavior: EventTrackingBehavior) { + trackingBehavior = behavior + if behavior != .all { + elements.removeAll() } - if placement is InternalSuperwallEvent.TriggerFire - || placement is InternalSuperwallEvent.UserAttributes - || placement is UserInitiatedPlacement.Track { + } + + private func trackingAllowed(from placement: Trackable) -> Bool { + switch trackingBehavior { + case .all: + return true + case .superwallOnly: + if placement is InternalSuperwallEvent.TriggerFire + || placement is InternalSuperwallEvent.UserAttributes + || placement is UserInitiatedPlacement.Track { + return false + } + return true + case .none: return false } - return true } func flushInternal(depth: Int = 10) { + if trackingBehavior == .none { + elements.removeAll() + return + } + var eventsToSend: [JSON] = [] var i = 0 diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index 6d3741917f..eab1066b66 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -62,6 +62,46 @@ public final class Superwall: NSObject, ObservableObject { } } + /// Controls which events are sent to the Superwall servers at runtime. + /// + /// Defaults to ``EventTrackingBehavior/all``. Update this at any point after + /// ``configure(apiKey:purchaseController:options:completion:)-52tke`` to change event + /// collection dynamically — for example, toggling to ``EventTrackingBehavior/none`` + /// after the user declines data collection in a GDPR consent flow. + /// + /// You can also set the initial value via ``SuperwallOptions/eventTrackingBehavior`` + /// before calling `configure`. + public var eventTrackingBehavior: EventTrackingBehavior { + get { + return options.eventTrackingBehavior + } + set { + options.eventTrackingBehavior = newValue + + Task { + await dependencyContainer.placementsQueue.setTrackingBehavior(newValue) + } + + let behavior = newValue + Task { @MainActor [weak self] in + self?.paywallViewController?.webView.messageHandler + .passEventTrackingBehaviorToWebView(behavior) + } + + // When opting out entirely, don't emit the config-attributes event. It + // races the `setTrackingBehavior(.none)` Task above, so a flush could + // transmit it before the queue's opt-out takes effect. + if newValue == .none { + return + } + + let configAttributes = dependencyContainer.makeConfigAttributes() + Task { + await track(configAttributes) + } + } + } + /// Defines the products to override on any paywall by product name. /// /// You can override one or more products of your choosing. For example, this is how you would override the first and third product on a paywall: diff --git a/SuperwallKit.podspec b/SuperwallKit.podspec index 8167b28f93..27d2d5647d 100644 --- a/SuperwallKit.podspec +++ b/SuperwallKit.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "SuperwallKit" - s.version = "4.15.4" + s.version = "4.16.0" s.summary = "Superwall: In-App Paywalls Made Easy" s.description = "Paywall infrastructure for mobile apps :) we make things like editing your paywall and running price tests as easy as clicking a few buttons. superwall.com" diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index b7d0592453..f8ba6205c3 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -142,6 +142,7 @@ 3CF2307C2CB994D00A35FADD /* LoadingModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 866F99509EDFBE8BAE10E575 /* LoadingModel.swift */; }; 3DCE95BAC148CCC7E6E7F608 /* DeviceTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3E5C31CEEDC9C2853D91C50 /* DeviceTemplate.swift */; }; 3EA92DE86764CBAC557F8522 /* Capabilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27E41300E7467F017BCD5E5C /* Capabilities.swift */; }; + 3EE4C1C4EC45718C2EED34E5 /* EventTrackingBehavior.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93D8033AAF5549E30ACDA3EA /* EventTrackingBehavior.swift */; }; 3F3774A066285BB0DFE61B61 /* JSONToDict.swift in Sources */ = {isa = PBXBuildFile; fileRef = E09C238ADC0B019047FAB1DF /* JSONToDict.swift */; }; 3F4BE7ECC80EEA757454F9B6 /* DependencyContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 124F219E38F8398A65A7EB32 /* DependencyContainer.swift */; }; 3F6DD6FB62BDF53536FC4EF7 /* V4Migrator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9D685A5912892EF9C2931B1 /* V4Migrator.swift */; }; @@ -517,6 +518,7 @@ E9F80BA7BCAB71270E0E1CC1 /* AttributionFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64EAB177118BC78B02C3C00A /* AttributionFetcher.swift */; }; E9F892ABB9BDA85F4794E3CF /* SubscriptionStatusResolutionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78C15CF29C17FE1EE3BFDEDC /* SubscriptionStatusResolutionTests.swift */; }; EA50607230AA07B509E90E10 /* TestStoreUser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7162E1E791297A3BF80B65A4 /* TestStoreUser.swift */; }; + EA66951B1DF341C4F0448C9F /* PlacementsQueueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 682AB10207309C439F64BC69 /* PlacementsQueueTests.swift */; }; EB1964816A8297CE133F96BF /* PurchaseError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49E522F5BCABB3A95B97549E /* PurchaseError.swift */; }; EB6540A8E1ECC3548C5E6368 /* PaywallMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC653A44D9B40812BDDD94E7 /* PaywallMessage.swift */; }; ECA7E9C9898CAB24B56E7054 /* SK2PriceFormatRoundingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC580BF1CC720ECBC4E68A28 /* SK2PriceFormatRoundingTests.swift */; }; @@ -807,6 +809,7 @@ 67602AF9B2543CAD0B42F3CF /* CacheMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheMock.swift; sourceTree = ""; }; 67C4FC41FEE0B47EA402D738 /* LocalizationConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizationConfig.swift; sourceTree = ""; }; 67D9C2B45E15025BF7D783AD /* zh_Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = zh_Hans; path = zh_Hans.lproj/Localizable.strings; sourceTree = ""; }; + 682AB10207309C439F64BC69 /* PlacementsQueueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlacementsQueueTests.swift; sourceTree = ""; }; 68363EE16B2DE5C1C5361657 /* Enrichment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Enrichment.swift; sourceTree = ""; }; 6944763A0D07AFA102B023C5 /* PaywallManagerLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallManagerLogicTests.swift; sourceTree = ""; }; 69A4D77D819DDB696834E1B7 /* UIViewController+AsyncPresent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+AsyncPresent.swift"; sourceTree = ""; }; @@ -911,6 +914,7 @@ 93604964336C40E763A7BFAF /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = ""; }; 937CAD765EA00E4A0FC86037 /* V2ProductsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = V2ProductsResponse.swift; sourceTree = ""; }; 938EB5121B1D9EA6B2EAE9EC /* Task+Retrying.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Task+Retrying.swift"; sourceTree = ""; }; + 93D8033AAF5549E30ACDA3EA /* EventTrackingBehavior.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventTrackingBehavior.swift; sourceTree = ""; }; 93FACE677755EAA3EA4E67A8 /* IntroOfferEligibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntroOfferEligibility.swift; sourceTree = ""; }; 94125DB9AC8A2EA66F983EDA /* PresentationResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresentationResult.swift; sourceTree = ""; }; 9423D7BA0604D88E30161BFB /* PaywallCacheLogic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallCacheLogic.swift; sourceTree = ""; }; @@ -2481,6 +2485,7 @@ A1EF6DDE57A911501DFB2B4D /* Storage */ = { isa = PBXGroup; children = ( + 682AB10207309C439F64BC69 /* PlacementsQueueTests.swift */, 6D1887F247BF6F770122F257 /* StorageMock.swift */, 787764B249892BBCA1088235 /* StorageTests.swift */, 0E582EAD2A75C1D7A72F2E52 /* Cache */, @@ -2941,6 +2946,7 @@ isa = PBXGroup; children = ( 23307BBFD80385233DDD4C43 /* AssetResource.swift */, + 93D8033AAF5549E30ACDA3EA /* EventTrackingBehavior.swift */, B1A64CCBCB23CC1715DF79AC /* PaywallOptions.swift */, CFDA311A030FDFC45AEE248A /* SuperwallOptions.swift */, ); @@ -3289,6 +3295,7 @@ 5F1F480AA12C8D17B179D96B /* PaywallViewControllerMock.swift in Sources */, ED246150DA2747AA42D6009C /* PermissionStatusTests.swift in Sources */, 4A4E5413A8753AFB624D325D /* PermissionTypeTests.swift in Sources */, + EA66951B1DF341C4F0448C9F /* PlacementsQueueTests.swift in Sources */, A3A0961A4A230C10B8896400 /* PopupTransitionTests.swift in Sources */, 3BE562844FD54486450CE6BB /* PresentPaywallOperatorTests.swift in Sources */, 3652D5EE4C172D623BDEE7E4 /* PresentationIdTests.swift in Sources */, @@ -3439,6 +3446,7 @@ DEF83BEF1ED06921BD55F55A /* EvaluationContext.swift in Sources */, 2653909358966BE9AC9894F1 /* EvaluationResult.swift in Sources */, 35597883CB038DBEE63E162B /* EventData.swift in Sources */, + 3EE4C1C4EC45718C2EED34E5 /* EventTrackingBehavior.swift in Sources */, F297363F2C4BFEE9D051D684 /* EventsRequest.swift in Sources */, 5FB78EB52A12BA704AD3AE31 /* EventsResponse.swift in Sources */, 412C74624BB8933162DAAC5F /* Experiment.swift in Sources */, diff --git a/Tests/SuperwallKitTests/Network/NetworkMock.swift b/Tests/SuperwallKitTests/Network/NetworkMock.swift index dff257b3d3..efb72aed35 100644 --- a/Tests/SuperwallKitTests/Network/NetworkMock.swift +++ b/Tests/SuperwallKitTests/Network/NetworkMock.swift @@ -11,6 +11,7 @@ import Combine final class NetworkMock: Network { var sentSessionEvents: SessionEventsRequest? + var sentEvents: [EventsRequest] = [] var getConfigCalled = false var assignmentsConfirmed = false var assignments: [PostbackAssignment] = [] @@ -29,6 +30,10 @@ final class NetworkMock: Network { var onRedeemEntitlements: (() -> Void)? var onPollRedemptionResult: (() -> Void)? + override func sendEvents(events: EventsRequest) async { + sentEvents.append(events) + } + override func sendSessionEvents(_ session: SessionEventsRequest) async { sentSessionEvents = session } diff --git a/Tests/SuperwallKitTests/Storage/PlacementsQueueTests.swift b/Tests/SuperwallKitTests/Storage/PlacementsQueueTests.swift new file mode 100644 index 0000000000..9949065957 --- /dev/null +++ b/Tests/SuperwallKitTests/Storage/PlacementsQueueTests.swift @@ -0,0 +1,324 @@ +// +// PlacementsQueueTests.swift +// +// +// Created by Yusuf Tör on 22/06/2026. +// +// swiftlint:disable all + +import Testing +import Foundation +@testable import SuperwallKit + +@Suite(.serialized) +struct PlacementsQueueTests { + private let stubJSON = JSON(["event": "test"]) + + // MARK: - EventTrackingBehavior.all + + @Test + func all_allowsUserInitiatedTrack() async throws { + let setup = makeQueue(behavior: .all) + + await setup.queue.enqueue( + data: stubJSON, + from: UserInitiatedPlacement.Track( + rawName: "test", + canImplicitlyTriggerPaywall: false, + isFeatureGatable: false + ) + ) + await setup.queue.flushInternal() + try await Task.sleep(nanoseconds: 100_000_000) + + #expect(setup.network.sentEvents.count == 1) + } + + @Test + func all_allowsTriggerFire() async throws { + let setup = makeQueue(behavior: .all) + + await setup.queue.enqueue( + data: stubJSON, + from: InternalSuperwallEvent.TriggerFire( + triggerResult: .noAudienceMatch([]), + triggerName: "test" + ) + ) + await setup.queue.flushInternal() + try await Task.sleep(nanoseconds: 100_000_000) + + #expect(setup.network.sentEvents.count == 1) + } + + @Test + func all_allowsUserAttributes() async throws { + let setup = makeQueue(behavior: .all) + + await setup.queue.enqueue( + data: stubJSON, + from: InternalSuperwallEvent.UserAttributes(appInstalledAtString: "now") + ) + await setup.queue.flushInternal() + try await Task.sleep(nanoseconds: 100_000_000) + + #expect(setup.network.sentEvents.count == 1) + } + + @Test + func all_allowsInternalEvent() async throws { + let setup = makeQueue(behavior: .all) + + await setup.queue.enqueue(data: stubJSON, from: InternalSuperwallEvent.AppOpen()) + await setup.queue.flushInternal() + try await Task.sleep(nanoseconds: 100_000_000) + + #expect(setup.network.sentEvents.count == 1) + } + + // MARK: - EventTrackingBehavior.superwallOnly + + @Test + func superwallOnly_blocksUserInitiatedTrack() async throws { + let setup = makeQueue(behavior: .superwallOnly) + + await setup.queue.enqueue( + data: stubJSON, + from: UserInitiatedPlacement.Track( + rawName: "test", + canImplicitlyTriggerPaywall: false, + isFeatureGatable: false + ) + ) + await setup.queue.flushInternal() + try await Task.sleep(nanoseconds: 100_000_000) + + #expect(setup.network.sentEvents.isEmpty) + } + + @Test + func superwallOnly_blocksTriggerFire() async throws { + let setup = makeQueue(behavior: .superwallOnly) + + await setup.queue.enqueue( + data: stubJSON, + from: InternalSuperwallEvent.TriggerFire( + triggerResult: .noAudienceMatch([]), + triggerName: "test" + ) + ) + await setup.queue.flushInternal() + try await Task.sleep(nanoseconds: 100_000_000) + + #expect(setup.network.sentEvents.isEmpty) + } + + @Test + func superwallOnly_blocksUserAttributes() async throws { + let setup = makeQueue(behavior: .superwallOnly) + + await setup.queue.enqueue( + data: stubJSON, + from: InternalSuperwallEvent.UserAttributes(appInstalledAtString: "now") + ) + await setup.queue.flushInternal() + try await Task.sleep(nanoseconds: 100_000_000) + + #expect(setup.network.sentEvents.isEmpty) + } + + @Test + func superwallOnly_allowsInternalEvent() async throws { + let setup = makeQueue(behavior: .superwallOnly) + + await setup.queue.enqueue(data: stubJSON, from: InternalSuperwallEvent.AppOpen()) + await setup.queue.flushInternal() + try await Task.sleep(nanoseconds: 100_000_000) + + #expect(setup.network.sentEvents.count == 1) + } + + @Test + func superwallOnly_allowsConfigAttributes() async throws { + // ConfigAttributes is a Superwall-internal event, so it is permitted under + // .superwallOnly — which is why the `eventTrackingBehavior` setter still + // tracks it for .superwallOnly and only skips it for .none. + let setup = makeQueue(behavior: .superwallOnly) + + await setup.queue.enqueue( + data: stubJSON, + from: InternalSuperwallEvent.ConfigAttributes( + options: SuperwallOptions(), + hasExternalPurchaseController: false, + hasDelegate: false + ) + ) + await setup.queue.flushInternal() + try await Task.sleep(nanoseconds: 100_000_000) + + #expect(setup.network.sentEvents.count == 1) + } + + // MARK: - EventTrackingBehavior.none + + @Test + func none_blocksAllEvents() async throws { + let setup = makeQueue(behavior: .none) + + await setup.queue.enqueue(data: stubJSON, from: InternalSuperwallEvent.AppOpen()) + await setup.queue.enqueue( + data: stubJSON, + from: InternalSuperwallEvent.UserAttributes(appInstalledAtString: "now") + ) + await setup.queue.enqueue( + data: stubJSON, + from: UserInitiatedPlacement.Track( + rawName: "test", + canImplicitlyTriggerPaywall: false, + isFeatureGatable: false + ) + ) + await setup.queue.enqueue( + data: stubJSON, + from: InternalSuperwallEvent.TriggerFire( + triggerResult: .noAudienceMatch([]), + triggerName: "test" + ) + ) + await setup.queue.flushInternal() + try await Task.sleep(nanoseconds: 100_000_000) + + #expect(setup.network.sentEvents.isEmpty) + } + + @Test + func none_blocksConfigAttributes() async throws { + // Backs up the `eventTrackingBehavior` setter's early return for .none: even + // if a config-attributes event reached the queue (losing the Task-ordering + // race), the queue still blocks it once the behavior is .none. + let setup = makeQueue(behavior: .none) + + await setup.queue.enqueue( + data: stubJSON, + from: InternalSuperwallEvent.ConfigAttributes( + options: SuperwallOptions(), + hasExternalPurchaseController: false, + hasDelegate: false + ) + ) + await setup.queue.flushInternal() + try await Task.sleep(nanoseconds: 100_000_000) + + #expect(setup.network.sentEvents.isEmpty) + } + + @Test + func superwallOnly_setTrackingBehaviorDiscardsBuffer() async throws { + let setup = makeQueue(behavior: .all) + await setup.queue.enqueue(data: stubJSON, from: InternalSuperwallEvent.AppOpen()) + await setup.queue.enqueue(data: stubJSON, from: InternalSuperwallEvent.AppOpen()) + + await setup.queue.setTrackingBehavior(.superwallOnly) + await setup.queue.flushInternal() + try await Task.sleep(nanoseconds: 100_000_000) + + #expect(setup.network.sentEvents.isEmpty) + } + + @Test + func none_setTrackingBehaviorDiscardsBufferAndBlocksFlush() async throws { + // Covers both the eager clear and the flush-time guard in one shot. + let setup = makeQueue(behavior: .all) + await setup.queue.enqueue(data: stubJSON, from: InternalSuperwallEvent.AppOpen()) + await setup.queue.enqueue(data: stubJSON, from: InternalSuperwallEvent.AppOpen()) + + // Simulate the runtime opt-out (mirrors what Superwall.shared setter does). + await setup.queue.setTrackingBehavior(.none) + await setup.queue.flushInternal() + try await Task.sleep(nanoseconds: 100_000_000) + + #expect(setup.network.sentEvents.isEmpty) + } + + // MARK: - Deprecated isExternalDataCollectionEnabled backwards compatibility + + @Test + func deprecatedFalse_mapsToSuperwallOnly() { + let options = SuperwallOptions() + options.isExternalDataCollectionEnabled = false + #expect(options.eventTrackingBehavior == .superwallOnly) + } + + @Test + func deprecatedTrue_mapsToAll() { + let options = SuperwallOptions() + options.eventTrackingBehavior = .superwallOnly + options.isExternalDataCollectionEnabled = true + #expect(options.eventTrackingBehavior == .all) + } + + @Test + func deprecatedFalse_preservesNone() { + let options = SuperwallOptions() + options.eventTrackingBehavior = .none + options.isExternalDataCollectionEnabled = false + #expect(options.eventTrackingBehavior == .none) + } + + @Test + func deprecatedGetter_trueWhenAll() { + let options = SuperwallOptions() + options.eventTrackingBehavior = .all + #expect(options.isExternalDataCollectionEnabled == true) + } + + @Test + func deprecatedGetter_falseWhenSuperwallOnly() { + let options = SuperwallOptions() + options.eventTrackingBehavior = .superwallOnly + #expect(options.isExternalDataCollectionEnabled == false) + } + + @Test + func deprecatedGetter_falseWhenNone() { + let options = SuperwallOptions() + options.eventTrackingBehavior = .none + #expect(options.isExternalDataCollectionEnabled == false) + } + + // MARK: - Helpers + + private struct QueueSetup { + let queue: PlacementsQueue + let network: NetworkMock + // Keep network and its dependencyContainer alive; network is the only unowned ref the queue holds. + let configManager: ConfigManager + let dependencyContainer: DependencyContainer + } + + private func makeQueue(behavior: EventTrackingBehavior) -> QueueSetup { + let options = SuperwallOptions() + options.eventTrackingBehavior = behavior + + let dependencyContainer = DependencyContainer() + let network = NetworkMock(options: options, factory: dependencyContainer) + let configManager = ConfigManager( + options: options, + storeKitManager: dependencyContainer.storeKitManager, + storage: dependencyContainer.storage, + network: network, + paywallManager: dependencyContainer.paywallManager, + deviceHelper: dependencyContainer.deviceHelper, + entitlementsInfo: dependencyContainer.entitlementsInfo, + webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, + factory: dependencyContainer + ) + let queue = PlacementsQueue(network: network, configManager: configManager) + return QueueSetup( + queue: queue, + network: network, + configManager: configManager, + dependencyContainer: dependencyContainer + ) + } +}