From c4c3d6bbe2934e7b60fac713c115fd99391bb727 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Mon, 22 Jun 2026 17:01:56 +0200 Subject: [PATCH 01/12] Add EventTrackingBehavior enum for GDPR-compliant event control Introduces `EventTrackingBehavior` (.all / .superwallOnly / .none) to replace the deprecated `isExternalDataCollectionEnabled` bool. The new enum is settable both at configure time via `SuperwallOptions.eventTrackingBehavior` and at runtime via `Superwall.shared.eventTrackingBehavior`, following the same pattern as `logLevel`. `.none` suppresses all events (GDPR consent flow use case); `.superwallOnly` mirrors the old `false` value by blocking only user-initiated events, trigger fires, and attribute updates. Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 7 + .../Options/EventTrackingBehavior.swift | 41 +++ .../Config/Options/SuperwallOptions.swift | 28 +- Sources/SuperwallKit/Misc/Constants.swift | 2 +- .../Storage/PlacementsQueue.swift | 20 +- Sources/SuperwallKit/Superwall.swift | 23 ++ SuperwallKit.podspec | 2 +- .../Network/NetworkMock.swift | 5 + .../Storage/PlacementsQueueTests.swift | 255 ++++++++++++++++++ 9 files changed, 368 insertions(+), 15 deletions(-) create mode 100644 Sources/SuperwallKit/Config/Options/EventTrackingBehavior.swift create mode 100644 Tests/SuperwallKitTests/Storage/PlacementsQueueTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a311c7eb0..b6aba6ead2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/superwall/Superwall-iOS/releases) on GitHub. +## 4.16.0 + +### Enhancements + +- 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`. + ## 4.15.4 ### Enhancements 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..4b150bfef9 100644 --- a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift +++ b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift @@ -297,12 +297,30 @@ 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``; + /// setting it back to `true` maps to ``EventTrackingBehavior/all``. + @available(*, deprecated, renamed: "eventTrackingBehavior") + public var isExternalDataCollectionEnabled: Bool { + get { + return eventTrackingBehavior == .all + } + set { + eventTrackingBehavior = newValue ? .all : .superwallOnly + } + } /// Sets the device locale identifier to use when evaluating audience filters. /// @@ -374,7 +392,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 +427,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/Storage/PlacementsQueue.swift b/Sources/SuperwallKit/Storage/PlacementsQueue.swift index 32befaf33c..d7259ec5a0 100644 --- a/Sources/SuperwallKit/Storage/PlacementsQueue.swift +++ b/Sources/SuperwallKit/Storage/PlacementsQueue.swift @@ -76,22 +76,26 @@ 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 { + private func trackingAllowed(from placement: Trackable) -> Bool { + switch Superwall.shared.options.eventTrackingBehavior { + case .all: return true - } - if placement is InternalSuperwallEvent.TriggerFire - || placement is InternalSuperwallEvent.UserAttributes - || placement is UserInitiatedPlacement.Track { + 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) { diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index 6d3741917f..aa95246f72 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -62,6 +62,29 @@ 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 + + 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/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..cdbc1e0d1c --- /dev/null +++ b/Tests/SuperwallKitTests/Storage/PlacementsQueueTests.swift @@ -0,0 +1,255 @@ +// +// 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) + } + + // 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) + } + + // 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 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: - Runtime Superwall.shared property + + @Test + func runtimeSetter_updatesOptions() { + Superwall.shared.eventTrackingBehavior = .superwallOnly + #expect(Superwall.shared.options.eventTrackingBehavior == .superwallOnly) + + Superwall.shared.eventTrackingBehavior = .all + #expect(Superwall.shared.options.eventTrackingBehavior == .all) + } + + // MARK: - Helpers + + private struct QueueSetup { + let queue: PlacementsQueue + let network: NetworkMock + let configManager: ConfigManager + let dependencyContainer: DependencyContainer + } + + private func makeQueue(behavior: EventTrackingBehavior) -> QueueSetup { + Superwall.shared.options.eventTrackingBehavior = behavior + + let dependencyContainer = DependencyContainer() + let network = NetworkMock(options: SuperwallOptions(), factory: dependencyContainer) + let configManager = ConfigManager( + options: SuperwallOptions(), + 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 + ) + } +} From 6d59aa74bb489ce8267fb8220a37585b27137aaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Mon, 22 Jun 2026 17:06:45 +0200 Subject: [PATCH 02/12] Update changelog --- CHANGELOG.md | 7 +------ SuperwallKit.xcodeproj/project.pbxproj | 8 ++++++++ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6aba6ead2..8f9f1daa69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,15 +6,10 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup ### 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`. -## 4.15.4 - -### Enhancements - -- Adds support for annual subscriptions that are billed monthly. - ### Fixes - Fixes a crash due to concurrent calls to `preloadAllPaywalls`. 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 */, From dcfa0b0fd922d291b5b82c6398269e40a2d608a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Mon, 22 Jun 2026 17:09:56 +0200 Subject: [PATCH 03/12] Address code review: use injected configManager and preserve .none in deprecated setter - trackingAllowed now reads configManager.options.eventTrackingBehavior instead of Superwall.shared to honour the DI contract (matches how networkEnvironment is read) - Deprecated isExternalDataCollectionEnabled setter now preserves .none when set to false, preventing a silent downgrade to .superwallOnly for stricter callers - Tests no longer mutate Superwall.shared global state; behavior is scoped to the injected SuperwallOptions instance - Add deprecatedFalse_preservesNone test to cover the new preservation behaviour Co-Authored-By: Claude Sonnet 4.6 --- .../Config/Options/SuperwallOptions.swift | 11 ++++++--- .../Storage/PlacementsQueue.swift | 2 +- .../Storage/PlacementsQueueTests.swift | 24 +++++++++++++------ 3 files changed, 26 insertions(+), 11 deletions(-) diff --git a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift index 4b150bfef9..b1ce71703b 100644 --- a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift +++ b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift @@ -310,15 +310,20 @@ public final class SuperwallOptions: NSObject, Encodable { /// Defaults to `true`. /// /// - Warning: Deprecated. Use ``eventTrackingBehavior`` instead. - /// Setting this to `false` maps to ``EventTrackingBehavior/superwallOnly``; - /// setting it back to `true` maps to ``EventTrackingBehavior/all``. + /// 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 { - eventTrackingBehavior = newValue ? .all : .superwallOnly + if newValue { + eventTrackingBehavior = .all + } else if eventTrackingBehavior != .none { + eventTrackingBehavior = .superwallOnly + } } } diff --git a/Sources/SuperwallKit/Storage/PlacementsQueue.swift b/Sources/SuperwallKit/Storage/PlacementsQueue.swift index d7259ec5a0..672ff244b5 100644 --- a/Sources/SuperwallKit/Storage/PlacementsQueue.swift +++ b/Sources/SuperwallKit/Storage/PlacementsQueue.swift @@ -83,7 +83,7 @@ actor PlacementsQueue { } private func trackingAllowed(from placement: Trackable) -> Bool { - switch Superwall.shared.options.eventTrackingBehavior { + switch configManager.options.eventTrackingBehavior { case .all: return true case .superwallOnly: diff --git a/Tests/SuperwallKitTests/Storage/PlacementsQueueTests.swift b/Tests/SuperwallKitTests/Storage/PlacementsQueueTests.swift index cdbc1e0d1c..a34df6115c 100644 --- a/Tests/SuperwallKitTests/Storage/PlacementsQueueTests.swift +++ b/Tests/SuperwallKitTests/Storage/PlacementsQueueTests.swift @@ -187,6 +187,14 @@ struct PlacementsQueueTests { #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() @@ -212,11 +220,12 @@ struct PlacementsQueueTests { @Test func runtimeSetter_updatesOptions() { - Superwall.shared.eventTrackingBehavior = .superwallOnly - #expect(Superwall.shared.options.eventTrackingBehavior == .superwallOnly) + let options = SuperwallOptions() + options.eventTrackingBehavior = .superwallOnly + #expect(options.eventTrackingBehavior == .superwallOnly) - Superwall.shared.eventTrackingBehavior = .all - #expect(Superwall.shared.options.eventTrackingBehavior == .all) + options.eventTrackingBehavior = .all + #expect(options.eventTrackingBehavior == .all) } // MARK: - Helpers @@ -229,12 +238,13 @@ struct PlacementsQueueTests { } private func makeQueue(behavior: EventTrackingBehavior) -> QueueSetup { - Superwall.shared.options.eventTrackingBehavior = behavior + let options = SuperwallOptions() + options.eventTrackingBehavior = behavior let dependencyContainer = DependencyContainer() - let network = NetworkMock(options: SuperwallOptions(), factory: dependencyContainer) + let network = NetworkMock(options: options, factory: dependencyContainer) let configManager = ConfigManager( - options: SuperwallOptions(), + options: options, storeKitManager: dependencyContainer.storeKitManager, storage: dependencyContainer.storage, network: network, From 29018b0618d49d91c6e6e4903e4cb6fea1b111c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Mon, 22 Jun 2026 17:12:56 +0200 Subject: [PATCH 04/12] Discard buffered events on flush when eventTrackingBehavior is .none Events enqueued while tracking was allowed could still be transmitted on the next timer tick or willResignActive flush after the user opts out via the GDPR consent flow. flushInternal now clears elements immediately and returns early when behavior is .none, preventing any pre-buffered events from being sent. Co-Authored-By: Claude Sonnet 4.6 --- .../SuperwallKit/Storage/PlacementsQueue.swift | 5 +++++ .../Storage/PlacementsQueueTests.swift | 16 ++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/Sources/SuperwallKit/Storage/PlacementsQueue.swift b/Sources/SuperwallKit/Storage/PlacementsQueue.swift index 672ff244b5..d18fad0444 100644 --- a/Sources/SuperwallKit/Storage/PlacementsQueue.swift +++ b/Sources/SuperwallKit/Storage/PlacementsQueue.swift @@ -99,6 +99,11 @@ actor PlacementsQueue { } func flushInternal(depth: Int = 10) { + if configManager.options.eventTrackingBehavior == .none { + elements.removeAll() + return + } + var eventsToSend: [JSON] = [] var i = 0 diff --git a/Tests/SuperwallKitTests/Storage/PlacementsQueueTests.swift b/Tests/SuperwallKitTests/Storage/PlacementsQueueTests.swift index a34df6115c..41550c3d00 100644 --- a/Tests/SuperwallKitTests/Storage/PlacementsQueueTests.swift +++ b/Tests/SuperwallKitTests/Storage/PlacementsQueueTests.swift @@ -170,6 +170,22 @@ struct PlacementsQueueTests { #expect(setup.network.sentEvents.isEmpty) } + @Test + func none_discardsAlreadyBufferedEvents() async throws { + // Events enqueued while .all, then behavior switches to .none before flush. + let setup = makeQueue(behavior: .all) + await setup.queue.enqueue(data: stubJSON, from: InternalSuperwallEvent.AppOpen()) + await setup.queue.enqueue(data: stubJSON, from: InternalSuperwallEvent.AppOpen()) + + // Simulate runtime opt-out before the timer fires. + setup.configManager.options.eventTrackingBehavior = .none + + await setup.queue.flushInternal() + try await Task.sleep(nanoseconds: 100_000_000) + + #expect(setup.network.sentEvents.isEmpty) + } + // MARK: - Deprecated isExternalDataCollectionEnabled backwards compatibility @Test From 7b366515934e550dd24ece358dc0007bca20ffa6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Mon, 22 Jun 2026 17:31:19 +0200 Subject: [PATCH 05/12] Remove redundant runtimeSetter_updatesOptions test The test was operating on a local SuperwallOptions instance rather than Superwall.shared, so it wasn't actually covering the runtime setter path it claimed to. The property round-trip it tested is already implicit in the deprecated-getter tests. Removed the test and its MARK section rather than keeping a misleading name. Co-Authored-By: Claude Sonnet 4.6 --- .../Storage/PlacementsQueueTests.swift | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/Tests/SuperwallKitTests/Storage/PlacementsQueueTests.swift b/Tests/SuperwallKitTests/Storage/PlacementsQueueTests.swift index 41550c3d00..8ab598187a 100644 --- a/Tests/SuperwallKitTests/Storage/PlacementsQueueTests.swift +++ b/Tests/SuperwallKitTests/Storage/PlacementsQueueTests.swift @@ -232,18 +232,6 @@ struct PlacementsQueueTests { #expect(options.isExternalDataCollectionEnabled == false) } - // MARK: - Runtime Superwall.shared property - - @Test - func runtimeSetter_updatesOptions() { - let options = SuperwallOptions() - options.eventTrackingBehavior = .superwallOnly - #expect(options.eventTrackingBehavior == .superwallOnly) - - options.eventTrackingBehavior = .all - #expect(options.eventTrackingBehavior == .all) - } - // MARK: - Helpers private struct QueueSetup { From d8e44972e7fbd9a395c4355324083a1f00943d4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Mon, 22 Jun 2026 17:36:46 +0200 Subject: [PATCH 06/12] Clear buffer at opt-out point rather than at flush time Stale events belong to the setter, not to flushInternal. When eventTrackingBehavior is set to .none at runtime, the setter now immediately calls placementsQueue.clearBuffer() so buffered events are discarded the moment the user opts out. flushInternal no longer needs to know about tracking behavior at all. Co-Authored-By: Claude Sonnet 4.6 --- Sources/SuperwallKit/Storage/PlacementsQueue.swift | 9 ++++----- Sources/SuperwallKit/Superwall.swift | 6 ++++++ .../SuperwallKitTests/Storage/PlacementsQueueTests.swift | 7 ++----- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/Sources/SuperwallKit/Storage/PlacementsQueue.swift b/Sources/SuperwallKit/Storage/PlacementsQueue.swift index d18fad0444..a53499130f 100644 --- a/Sources/SuperwallKit/Storage/PlacementsQueue.swift +++ b/Sources/SuperwallKit/Storage/PlacementsQueue.swift @@ -98,12 +98,11 @@ actor PlacementsQueue { } } - func flushInternal(depth: Int = 10) { - if configManager.options.eventTrackingBehavior == .none { - elements.removeAll() - return - } + func clearBuffer() { + elements.removeAll() + } + func flushInternal(depth: Int = 10) { var eventsToSend: [JSON] = [] var i = 0 diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index aa95246f72..8e175aaff8 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -78,6 +78,12 @@ public final class Superwall: NSObject, ObservableObject { set { options.eventTrackingBehavior = newValue + if newValue == .none { + Task { + await dependencyContainer.placementsQueue.clearBuffer() + } + } + let configAttributes = dependencyContainer.makeConfigAttributes() Task { await track(configAttributes) diff --git a/Tests/SuperwallKitTests/Storage/PlacementsQueueTests.swift b/Tests/SuperwallKitTests/Storage/PlacementsQueueTests.swift index 8ab598187a..8a2854bd21 100644 --- a/Tests/SuperwallKitTests/Storage/PlacementsQueueTests.swift +++ b/Tests/SuperwallKitTests/Storage/PlacementsQueueTests.swift @@ -171,15 +171,12 @@ struct PlacementsQueueTests { } @Test - func none_discardsAlreadyBufferedEvents() async throws { - // Events enqueued while .all, then behavior switches to .none before flush. + func clearBuffer_discardsAllBufferedEvents() 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()) - // Simulate runtime opt-out before the timer fires. - setup.configManager.options.eventTrackingBehavior = .none - + await setup.queue.clearBuffer() await setup.queue.flushInternal() try await Task.sleep(nanoseconds: 100_000_000) From 4b645d927cde7c598b3ff55335bc2a242bc26876 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Mon, 22 Jun 2026 17:41:39 +0200 Subject: [PATCH 07/12] Restore .none guard in flushInternal to close flush/clearBuffer race clearBuffer() and flushInternal() are both dispatched as unordered Tasks on the actor, so flushInternal can drain and send buffered events before clearBuffer gets a turn. The guard makes the behavior check and the drain atomic within the same actor turn, eliminating the window. clearBuffer() at the setter stays as an eager cleanup but the guard is what provides the correctness guarantee. Co-Authored-By: Claude Sonnet 4.6 --- .../SuperwallKit/Storage/PlacementsQueue.swift | 5 +++++ .../Storage/PlacementsQueueTests.swift | 16 ++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/Sources/SuperwallKit/Storage/PlacementsQueue.swift b/Sources/SuperwallKit/Storage/PlacementsQueue.swift index a53499130f..95c2ff6447 100644 --- a/Sources/SuperwallKit/Storage/PlacementsQueue.swift +++ b/Sources/SuperwallKit/Storage/PlacementsQueue.swift @@ -103,6 +103,11 @@ actor PlacementsQueue { } func flushInternal(depth: Int = 10) { + if configManager.options.eventTrackingBehavior == .none { + elements.removeAll() + return + } + var eventsToSend: [JSON] = [] var i = 0 diff --git a/Tests/SuperwallKitTests/Storage/PlacementsQueueTests.swift b/Tests/SuperwallKitTests/Storage/PlacementsQueueTests.swift index 8a2854bd21..9fd6eee7c2 100644 --- a/Tests/SuperwallKitTests/Storage/PlacementsQueueTests.swift +++ b/Tests/SuperwallKitTests/Storage/PlacementsQueueTests.swift @@ -183,6 +183,22 @@ struct PlacementsQueueTests { #expect(setup.network.sentEvents.isEmpty) } + @Test + func none_flushAfterOptOutSendsNothing() async throws { + // Simulates the race where flushInternal runs after the behavior is switched + // to .none but before clearBuffer() has had a turn on the actor. + let setup = makeQueue(behavior: .all) + await setup.queue.enqueue(data: stubJSON, from: InternalSuperwallEvent.AppOpen()) + await setup.queue.enqueue(data: stubJSON, from: InternalSuperwallEvent.AppOpen()) + + setup.configManager.options.eventTrackingBehavior = .none + + await setup.queue.flushInternal() + try await Task.sleep(nanoseconds: 100_000_000) + + #expect(setup.network.sentEvents.isEmpty) + } + // MARK: - Deprecated isExternalDataCollectionEnabled backwards compatibility @Test From 8e8ae9b86bbf71868da667d9aa3b3c65e0daf046 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Mon, 22 Jun 2026 17:46:14 +0200 Subject: [PATCH 08/12] Remove configManager from PlacementsQueue async paths to fix SIGABRT Both setupTimer and flushInternal accessed configManager (unowned) from Tasks that can outlive the test's QueueSetup, causing SIGABRT when the unowned reference became dangling. Fix: capture what's needed from configManager synchronously during init (timerInterval, trackingBehavior) and drop the stored reference from all async paths. trackingBehavior is now a local actor property updated via setTrackingBehavior(), which Superwall.shared calls on every eventTrackingBehavior change instead of the former clearBuffer(). Co-Authored-By: Claude Sonnet 4.6 --- .../Storage/PlacementsQueue.swift | 38 ++++++++++--------- Sources/SuperwallKit/Superwall.swift | 6 +-- .../Storage/PlacementsQueueTests.swift | 23 +++-------- 3 files changed, 28 insertions(+), 39 deletions(-) diff --git a/Sources/SuperwallKit/Storage/PlacementsQueue.swift b/Sources/SuperwallKit/Storage/PlacementsQueue.swift index 95c2ff6447..9ea3ac5229 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 { @@ -82,8 +83,15 @@ actor PlacementsQueue { elements.append(data) } + func setTrackingBehavior(_ behavior: EventTrackingBehavior) { + trackingBehavior = behavior + if behavior == .none { + elements.removeAll() + } + } + private func trackingAllowed(from placement: Trackable) -> Bool { - switch configManager.options.eventTrackingBehavior { + switch trackingBehavior { case .all: return true case .superwallOnly: @@ -98,12 +106,8 @@ actor PlacementsQueue { } } - func clearBuffer() { - elements.removeAll() - } - func flushInternal(depth: Int = 10) { - if configManager.options.eventTrackingBehavior == .none { + if trackingBehavior == .none { elements.removeAll() return } diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index 8e175aaff8..72c60e46ef 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -78,10 +78,8 @@ public final class Superwall: NSObject, ObservableObject { set { options.eventTrackingBehavior = newValue - if newValue == .none { - Task { - await dependencyContainer.placementsQueue.clearBuffer() - } + Task { + await dependencyContainer.placementsQueue.setTrackingBehavior(newValue) } let configAttributes = dependencyContainer.makeConfigAttributes() diff --git a/Tests/SuperwallKitTests/Storage/PlacementsQueueTests.swift b/Tests/SuperwallKitTests/Storage/PlacementsQueueTests.swift index 9fd6eee7c2..70ef24a598 100644 --- a/Tests/SuperwallKitTests/Storage/PlacementsQueueTests.swift +++ b/Tests/SuperwallKitTests/Storage/PlacementsQueueTests.swift @@ -171,28 +171,14 @@ struct PlacementsQueueTests { } @Test - func clearBuffer_discardsAllBufferedEvents() async throws { + 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()) - await setup.queue.clearBuffer() - await setup.queue.flushInternal() - try await Task.sleep(nanoseconds: 100_000_000) - - #expect(setup.network.sentEvents.isEmpty) - } - - @Test - func none_flushAfterOptOutSendsNothing() async throws { - // Simulates the race where flushInternal runs after the behavior is switched - // to .none but before clearBuffer() has had a turn on the actor. - let setup = makeQueue(behavior: .all) - await setup.queue.enqueue(data: stubJSON, from: InternalSuperwallEvent.AppOpen()) - await setup.queue.enqueue(data: stubJSON, from: InternalSuperwallEvent.AppOpen()) - - setup.configManager.options.eventTrackingBehavior = .none - + // 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) @@ -250,6 +236,7 @@ struct PlacementsQueueTests { private struct QueueSetup { let queue: PlacementsQueue let network: NetworkMock + // Keep configManager and dependencyContainer alive so unowned refs inside the queue remain valid. let configManager: ConfigManager let dependencyContainer: DependencyContainer } From eb116c9335c43421016005dd4e23ceb6335474c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Mon, 22 Jun 2026 17:51:23 +0200 Subject: [PATCH 09/12] Update stale QueueSetup comment to reflect current unowned refs After the configManager refactor, the queue only holds an unowned reference to network. Update the comment accordingly. Co-Authored-By: Claude Sonnet 4.6 --- Tests/SuperwallKitTests/Storage/PlacementsQueueTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/SuperwallKitTests/Storage/PlacementsQueueTests.swift b/Tests/SuperwallKitTests/Storage/PlacementsQueueTests.swift index 70ef24a598..59159517d3 100644 --- a/Tests/SuperwallKitTests/Storage/PlacementsQueueTests.swift +++ b/Tests/SuperwallKitTests/Storage/PlacementsQueueTests.swift @@ -236,7 +236,7 @@ struct PlacementsQueueTests { private struct QueueSetup { let queue: PlacementsQueue let network: NetworkMock - // Keep configManager and dependencyContainer alive so unowned refs inside the queue remain valid. + // Keep network and its dependencyContainer alive; network is the only unowned ref the queue holds. let configManager: ConfigManager let dependencyContainer: DependencyContainer } From 5e4abbfbd4388e64ca9454f5c85e6560c8437db6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Mon, 22 Jun 2026 18:01:54 +0200 Subject: [PATCH 10/12] Discard buffer on any transition away from .all, not just .none Events already in the buffer when switching to .superwallOnly would still be transmitted on the next flush because the buffer stores raw JSON with no type information and can't be selectively filtered. Clearing on any non-.all transition prevents user-initiated events from escaping a runtime opt-out at the cost of at most maxEventCount internal events, which will naturally reappear. Co-Authored-By: Claude Sonnet 4.6 --- Sources/SuperwallKit/Storage/PlacementsQueue.swift | 2 +- .../Storage/PlacementsQueueTests.swift | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/Sources/SuperwallKit/Storage/PlacementsQueue.swift b/Sources/SuperwallKit/Storage/PlacementsQueue.swift index 9ea3ac5229..f0cdb8e6ee 100644 --- a/Sources/SuperwallKit/Storage/PlacementsQueue.swift +++ b/Sources/SuperwallKit/Storage/PlacementsQueue.swift @@ -85,7 +85,7 @@ actor PlacementsQueue { func setTrackingBehavior(_ behavior: EventTrackingBehavior) { trackingBehavior = behavior - if behavior == .none { + if behavior != .all { elements.removeAll() } } diff --git a/Tests/SuperwallKitTests/Storage/PlacementsQueueTests.swift b/Tests/SuperwallKitTests/Storage/PlacementsQueueTests.swift index 59159517d3..6e0ee04d4a 100644 --- a/Tests/SuperwallKitTests/Storage/PlacementsQueueTests.swift +++ b/Tests/SuperwallKitTests/Storage/PlacementsQueueTests.swift @@ -170,6 +170,19 @@ struct PlacementsQueueTests { #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. From 04704cbd8cf68af858401a3729e5bab23b01a75e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:17:39 +0200 Subject: [PATCH 11/12] Send event_tracking_behavior to paywall web view for GDPR compliance When the paywall web view loads, the current eventTrackingBehavior is sent via accept64 so the JS layer can disable its own collector calls. The same message is sent immediately on runtime behavior changes so any currently-open paywall stops tracking without requiring a reload. Co-Authored-By: Claude Sonnet 4.6 --- .../Message Handling/PaywallMessageHandler.swift | 14 ++++++++++++++ Sources/SuperwallKit/Superwall.swift | 6 ++++++ 2 files changed, 20 insertions(+) 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/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index 72c60e46ef..5239101a9d 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -82,6 +82,12 @@ public final class Superwall: NSObject, ObservableObject { await dependencyContainer.placementsQueue.setTrackingBehavior(newValue) } + let behavior = newValue + Task { @MainActor [weak self] in + self?.paywallViewController?.webView.messageHandler + .passEventTrackingBehaviorToWebView(behavior) + } + let configAttributes = dependencyContainer.makeConfigAttributes() Task { await track(configAttributes) From 3be0457fae316a3b6510b6a05650c137615c3754 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:17:16 +0200 Subject: [PATCH 12/12] Skip config-attributes tracking when opting out to .none When eventTrackingBehavior is set to .none, the config-attributes event and the placementsQueue opt-out were dispatched as independent unstructured Tasks. If the config-attributes track reached PlacementsQueue.enqueue before the queue flipped its local trackingBehavior, a flush could transmit it, defeating the opt-out. Skip the track call entirely for .none. Add PlacementsQueue tests confirming ConfigAttributes is blocked under .none and allowed under .superwallOnly. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/SuperwallKit/Superwall.swift | 7 ++++ .../Storage/PlacementsQueueTests.swift | 42 +++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index 5239101a9d..eab1066b66 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -88,6 +88,13 @@ public final class Superwall: NSObject, ObservableObject { .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) diff --git a/Tests/SuperwallKitTests/Storage/PlacementsQueueTests.swift b/Tests/SuperwallKitTests/Storage/PlacementsQueueTests.swift index 6e0ee04d4a..9949065957 100644 --- a/Tests/SuperwallKitTests/Storage/PlacementsQueueTests.swift +++ b/Tests/SuperwallKitTests/Storage/PlacementsQueueTests.swift @@ -138,6 +138,27 @@ struct PlacementsQueueTests { #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 @@ -170,6 +191,27 @@ struct PlacementsQueueTests { #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)