From d02c2ea344a5b1ffff69adb9c4ba573b708041de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:21:12 +0200 Subject: [PATCH 01/15] Add ad consent option reported as device attributes Adds `SuperwallOptions.adConsent` and `Superwall.shared.adConsent` (`AdConsent` with `adUserData` and `adPersonalization`, each a `ConsentStatus` of granted or denied, both granted by default). The SDK reports them as the `adUserDataConsent` and `adPersonalizationConsent` device attributes ("granted"/"denied"), which the server forwards to Google Ads with uploaded conversions. Both report denied while `eventTrackingBehavior` is `.none`. Setting `Superwall.shared.adConsent` re-sends config and device attributes so the change lands promptly. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../Config/Options/AdConsent.swift | 61 +++++++++++++ .../Config/Options/SuperwallOptions.swift | 14 +++ .../Network/Device Helper/DeviceHelper.swift | 13 ++- .../Templating/Models/DeviceTemplate.swift | 2 + Sources/SuperwallKit/Superwall.swift | 25 ++++++ SuperwallKit.xcodeproj/project.pbxproj | 8 ++ .../Config/AdConsentTests.swift | 89 +++++++++++++++++++ 8 files changed, 211 insertions(+), 2 deletions(-) create mode 100644 Sources/SuperwallKit/Config/Options/AdConsent.swift create mode 100644 Tests/SuperwallKitTests/Config/AdConsentTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index e1669d3f91..ad516c7e36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup - Adds the Customer Center, a self-service screen where users can view, restore, manage, cancel, refund and change their purchases, and contact support. Present it with `Superwall.shared.presentCustomerCenter()`, `CustomerCenterView` or `CustomerCenterViewController`, and configure it with `SuperwallOptions.customerCenter`. - Adds `CustomerCenterDelegate` and events for when the Customer Center opens, closes, and when users pick an action, answer a survey or request a refund. - Adds best-effort public IPv4 and IPv6 device attributes, with separate observation timestamps, for apps using the Superwall MMP. +- Adds `SuperwallOptions.adConsent` and `Superwall.shared.adConsent` to report a user's ad consent (`adUserData` and `adPersonalization`), which Superwall passes on to ad networks such as Google Ads when it reports conversions. Both default to granted. If your app has users in the EEA, the UK or Switzerland, set them from your consent flow. They're reported as the `adUserDataConsent` and `adPersonalizationConsent` device attributes, and as denied while `eventTrackingBehavior` is `.none`. ### Fixes diff --git a/Sources/SuperwallKit/Config/Options/AdConsent.swift b/Sources/SuperwallKit/Config/Options/AdConsent.swift new file mode 100644 index 0000000000..9b8518caa7 --- /dev/null +++ b/Sources/SuperwallKit/Config/Options/AdConsent.swift @@ -0,0 +1,61 @@ +// +// AdConsent.swift +// +// +// Created by Yusuf Tör on 08/10/2026. +// + +import Foundation + +/// Whether a user has granted or denied consent for an advertising purpose. +@objc(SWKConsentStatus) +public enum ConsentStatus: Int, CustomStringConvertible, Sendable { + /// The user has granted consent. This is the default. + case granted = 0 + + /// The user has denied consent. + case denied = 1 + + public var description: String { + switch self { + case .granted: return "granted" + case .denied: return "denied" + } + } +} + +/// A user's consent for how their data is used for advertising. +/// +/// Superwall passes this on to ad networks, such as Google Ads, when it reports +/// conversions for your app. Both values default to ``ConsentStatus/granted``. +@objc(SWKAdConsent) +@objcMembers +public final class AdConsent: NSObject { + /// Consent to send the user's data to ad networks for advertising. + public var adUserData: ConsentStatus + + /// Consent to use the user's data for personalized advertising. + public var adPersonalization: ConsentStatus + + public init( + adUserData: ConsentStatus = .granted, + adPersonalization: ConsentStatus = .granted + ) { + self.adUserData = adUserData + self.adPersonalization = adPersonalization + super.init() + } + + public override convenience init() { + self.init(adUserData: .granted, adPersonalization: .granted) + } + + /// The consent to report, which is denied for both purposes while + /// `eventTrackingBehavior` is ``EventTrackingBehavior/none``. + func reported(for eventTrackingBehavior: EventTrackingBehavior) -> AdConsent { + if eventTrackingBehavior == .none { + return AdConsent(adUserData: .denied, adPersonalization: .denied) + } + return AdConsent(adUserData: adUserData, adPersonalization: adPersonalization) + } +} diff --git a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift index aaf0277ef9..359f9c19ef 100644 --- a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift +++ b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift @@ -364,6 +364,16 @@ public final class SuperwallOptions: NSObject, Encodable { /// You can also change this at runtime via ``Superwall/eventTrackingBehavior``. public var eventTrackingBehavior: EventTrackingBehavior = .all + /// The user's consent for how their data is used for advertising, which Superwall + /// passes on to ad networks such as Google Ads when it reports conversions. + /// + /// Both values default to ``ConsentStatus/granted``. If your app has users in the + /// EEA, the UK or Switzerland, set this from your consent flow. Both are reported as + /// denied while ``eventTrackingBehavior`` is ``EventTrackingBehavior/none``. + /// + /// You can also change this at runtime via ``Superwall/adConsent``. + public var adConsent = AdConsent() + /// Enables the sending of non-Superwall tracked events and properties back to the Superwall servers. /// Defaults to `true`. /// @@ -536,6 +546,8 @@ public final class SuperwallOptions: NSObject, Encodable { case shouldObservePurchases case enableExperimentalDeviceVariables case testModeBehavior + case adUserDataConsent + case adPersonalizationConsent } public override init() { @@ -571,6 +583,8 @@ public final class SuperwallOptions: NSObject, Encodable { try container.encode(shouldObservePurchases, forKey: .shouldObservePurchases) try container.encode(enableExperimentalDeviceVariables, forKey: .enableExperimentalDeviceVariables) try container.encode(testModeBehavior.description, forKey: .testModeBehavior) + try container.encode(adConsent.adUserData.description, forKey: .adUserDataConsent) + try container.encode(adConsent.adPersonalization.description, forKey: .adPersonalizationConsent) } func toDictionary() -> [String: Any] { diff --git a/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift index 16d6105d14..afe561fbcb 100644 --- a/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift +++ b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift @@ -916,6 +916,7 @@ class DeviceHelper { & LocaleIdentifierFactory & WebEntitlementFactory & ConfigStateFactory + & OptionsFactory init( api: Api, @@ -923,7 +924,11 @@ class DeviceHelper { network: Network, entitlementsInfo: EntitlementsInfo, receiptManager: ReceiptManager, - factory: IdentityFactory & LocaleIdentifierFactory & WebEntitlementFactory & ConfigStateFactory, + factory: IdentityFactory + & LocaleIdentifierFactory + & WebEntitlementFactory + & ConfigStateFactory + & OptionsFactory, ipCollector: DeviceIPCollector? = nil, isUIKitReadSafe: @escaping () -> Bool = { DeviceHelper.isUIKitReadSafe } ) { @@ -1009,6 +1014,8 @@ class DeviceHelper { // inline rather than going through ``interfaceStyle``, so the two resolve it // the same way by hand — keep them in step. let traits = currentUITraits + let options = factory.makeSuperwallOptions() + let adConsent = options.adConsent.reported(for: options.eventTrackingBehavior) let template = DeviceTemplate( publicApiKey: storage.apiKey, @@ -1071,7 +1078,9 @@ class DeviceHelper { swiftVersion: currentSwiftVersion(), compilerVersion: currentCompilerVersion(), localResourceIds: Superwall.shared.options.localResources.keys.sorted().joined(separator: ","), - deviceId: factory.makeDeviceId() + deviceId: factory.makeDeviceId(), + adUserDataConsent: adConsent.adUserData.description, + adPersonalizationConsent: adConsent.adPersonalization.description ) var deviceDictionary = template.toDictionary( diff --git a/Sources/SuperwallKit/Paywall/View Controller/Web View/Templating/Models/DeviceTemplate.swift b/Sources/SuperwallKit/Paywall/View Controller/Web View/Templating/Models/DeviceTemplate.swift index 31427cb16f..aca56d6a87 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/Web View/Templating/Models/DeviceTemplate.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/Web View/Templating/Models/DeviceTemplate.swift @@ -69,6 +69,8 @@ struct DeviceTemplate: Codable { var compilerVersion: String var localResourceIds: String var deviceId: String + var adUserDataConsent: String + var adPersonalizationConsent: String func toDictionary(encoder: JSONEncoder = JSONEncoder()) -> [String: Any] { guard let data = try? encoder.encode(self) else { diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index 3a510a1c82..8aab5835c1 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -106,6 +106,31 @@ public final class Superwall: NSObject, ObservableObject { } } + /// The user's consent for how their data is used for advertising, which Superwall + /// passes on to ad networks such as Google Ads when it reports conversions. + /// + /// Both values default to ``ConsentStatus/granted``. If your app has users in the + /// EEA, the UK or Switzerland, set this from your consent flow. Assign a new + /// ``AdConsent`` to send the change to Superwall straight away. + /// + /// You can also set the initial value via ``SuperwallOptions/adConsent`` + /// before calling `configure`. + public var adConsent: AdConsent { + get { + return options.adConsent + } + set { + options.adConsent = newValue + + let configAttributes = dependencyContainer.makeConfigAttributes() + Task { + await track(configAttributes) + let deviceAttributes = await dependencyContainer.makeSessionDeviceAttributes() + await track(InternalSuperwallEvent.DeviceAttributes(deviceAttributes: deviceAttributes)) + } + } + } + /// 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.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index ba50b72473..76c0800ff1 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -84,6 +84,7 @@ 20DD915D754F7819C44A677F /* PaywallInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 299441C3A6D3948BC5E0C741 /* PaywallInfo.swift */; }; 210BEE229900A19606803EDC /* SurveyOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03347D6B3B672A878CA2A7CA /* SurveyOption.swift */; }; 213E7FC262106BFBC44462AC /* SWLocalizationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBE7D1E1AF61D199E17B5C05 /* SWLocalizationViewController.swift */; }; + 2200A6F9DE53E95064A17A20 /* AdConsent.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE25827D6C0FFD2E3AEB5E1A /* AdConsent.swift */; }; 2205A0CC8F059B3D6231C603 /* PaywallLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A7140A8B0B006F2080D8915 /* PaywallLogicTests.swift */; }; 2231B31B4B9A25778069B20A /* PaddleProduct.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F17CFCD6B3B96A609A5B870 /* PaddleProduct.swift */; }; 223E12D4EDBF41D8F5515E53 /* SerialTaskCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D555619C0738B6016C62DF0E /* SerialTaskCoordinator.swift */; }; @@ -149,6 +150,7 @@ 36B598D26A38D50CC713FD73 /* ProductsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 641BC3C3F8AC2D6E1EF44D55 /* ProductsManager.swift */; }; 37264FFAF68B8349BD6F9BE8 /* WebEntitlementRedeemerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2D8F44D23EDD6316E1C4EE1 /* WebEntitlementRedeemerTests.swift */; }; 37FDB46DD55E649FA10D753C /* CustomerInfoDecodingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B300DB3367C74A42536395 /* CustomerInfoDecodingTests.swift */; }; + 38120C3BF1C0826E5D221ED3 /* AdConsentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2ABE060CEC937FA10038A356 /* AdConsentTests.swift */; }; 3824D48F8E0AF35EEBED8FF4 /* PaywallViewControllerWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22D96B4C9B546F7B0EC73397 /* PaywallViewControllerWrapper.swift */; }; 3860BFEF3E9A4F76A90480F1 /* TestModeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF301A818CE83F4EBB3A7003 /* TestModeManager.swift */; }; 38A74D299EDFCA3F0A5261B7 /* TransactionErrorLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76A719AD2F4475F83DFC9575 /* TransactionErrorLogic.swift */; }; @@ -829,6 +831,7 @@ 2A0325FB47A06456B909BCB5 /* TestModeRestoreDrawer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestModeRestoreDrawer.swift; sourceTree = ""; }; 2A50766D6FBAFA61D1121B51 /* DevicePreloadScript.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevicePreloadScript.swift; sourceTree = ""; }; 2AA2DEE352680123DB866281 /* CustomerCenterSheetOwnershipTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerCenterSheetOwnershipTests.swift; sourceTree = ""; }; + 2ABE060CEC937FA10038A356 /* AdConsentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdConsentTests.swift; sourceTree = ""; }; 2ACDC7427B6340E9D86F9B0F /* SubscriptionTransaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionTransaction.swift; sourceTree = ""; }; 2B40F35B536F527B029D7BDE /* SWProductSubscriptionPeriod.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SWProductSubscriptionPeriod.swift; sourceTree = ""; }; 2B42460730F1CDB856A35CFA /* Attribution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attribution.swift; sourceTree = ""; }; @@ -1351,6 +1354,7 @@ DAF94D451CB6346AE3C5863B /* ASN1Decoder+KeyedDecodingContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ASN1Decoder+KeyedDecodingContainer.swift"; sourceTree = ""; }; DB518DC9EFF0FE0C3A022982 /* String+RFC339.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+RFC339.swift"; sourceTree = ""; }; DD7F90791145963999DDA319 /* TaskCoalescer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskCoalescer.swift; sourceTree = ""; }; + DE25827D6C0FFD2E3AEB5E1A /* AdConsent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdConsent.swift; sourceTree = ""; }; DEF0596D5BDDE0911046E60D /* IntroOfferToken.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntroOfferToken.swift; sourceTree = ""; }; DEFDA9310B29A0C918D7A292 /* EventsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventsResponse.swift; sourceTree = ""; }; DF4B8468A75C8948EEFC6D4E /* StoreKitTransactionLookupMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreKitTransactionLookupMock.swift; sourceTree = ""; }; @@ -3349,6 +3353,7 @@ D554340BB6652F5FA1F21FF8 /* Config */ = { isa = PBXGroup; children = ( + 2ABE060CEC937FA10038A356 /* AdConsentTests.swift */, 97D7F499B2CBFFF0A61F8D72 /* ConfigLogicTests.swift */, 779C974DE83DCC78D75A59DC /* ConfigManagerEarlyPublishTests.swift */, DAAE50F011668C1D62B86751 /* ConfigManagerMock.swift */, @@ -3470,6 +3475,7 @@ E7D208EAB24A633EB74B1E31 /* Options */ = { isa = PBXGroup; children = ( + DE25827D6C0FFD2E3AEB5E1A /* AdConsent.swift */, 23307BBFD80385233DDD4C43 /* AssetResource.swift */, 93D8033AAF5549E30ACDA3EA /* EventTrackingBehavior.swift */, B1A64CCBCB23CC1715DF79AC /* PaywallOptions.swift */, @@ -3760,6 +3766,7 @@ buildActionMask = 2147483647; files = ( BC30A871540635D2D9AF8C5D /* AccentColorRoundTripTests.swift in Sources */, + 38120C3BF1C0826E5D221ED3 /* AdConsentTests.swift in Sources */, C2A9B3F073EA27F9CD6FCA02 /* AdServicesAttributionTests.swift in Sources */, 9B49485A1CFAC2621A89B150 /* AppSessionLogicTests.swift in Sources */, 1E81A71ADE8A5EAD9E609E1D /* AppSessionManagerMock.swift in Sources */, @@ -3959,6 +3966,7 @@ 2C7867867DDAD83E5856ECC9 /* ASN1Types.swift in Sources */, AC13B2D29FF61BDC995132DD /* AccountDetailsSection.swift in Sources */, 5E05FDE4F45BD5B0DF6AFB9F /* ActivityIndicatorView.swift in Sources */, + 2200A6F9DE53E95064A17A20 /* AdConsent.swift in Sources */, AECD80682E1909735CCDAA78 /* AdServicesAttributionAttempts.swift in Sources */, ED575DD46B84EE351972AC6B /* AdServicesResponse.swift in Sources */, 90EE0190126A3BBA4661122E /* AddPaywallProducts.swift in Sources */, diff --git a/Tests/SuperwallKitTests/Config/AdConsentTests.swift b/Tests/SuperwallKitTests/Config/AdConsentTests.swift new file mode 100644 index 0000000000..60a49d52f2 --- /dev/null +++ b/Tests/SuperwallKitTests/Config/AdConsentTests.swift @@ -0,0 +1,89 @@ +// +// AdConsentTests.swift +// SuperwallKit +// +// Created by Yusuf Tör on 08/10/2026. +// + +@testable import SuperwallKit +import Testing +import Foundation + +struct AdConsentTests { + @Test func defaults_areGranted() { + let consent = AdConsent() + #expect(consent.adUserData == .granted) + #expect(consent.adPersonalization == .granted) + + let options = SuperwallOptions() + #expect(options.adConsent.adUserData == .granted) + #expect(options.adConsent.adPersonalization == .granted) + } + + @Test func consentStatus_descriptionsMatchTheServerContract() { + #expect(ConsentStatus.granted.description == "granted") + #expect(ConsentStatus.denied.description == "denied") + } + + @Test func templateDevice_reportsGrantedByDefault() async { + let dependencyContainer = DependencyContainer(cache: CacheMock()) + + let template = await dependencyContainer.deviceHelper.getTemplateDevice() + + #expect(template["adUserDataConsent"] as? String == "granted") + #expect(template["adPersonalizationConsent"] as? String == "granted") + } + + @Test func settingAdConsentAtRuntime_isReflectedInDeviceAttributes() async { + let dependencyContainer = DependencyContainer(cache: CacheMock()) + let superwall = Superwall(dependencyContainer: dependencyContainer) + + superwall.adConsent = AdConsent(adUserData: .denied, adPersonalization: .granted) + + #expect(dependencyContainer.configManager.options.adConsent.adUserData == .denied) + let template = await dependencyContainer.deviceHelper.getTemplateDevice() + #expect(template["adUserDataConsent"] as? String == "denied") + #expect(template["adPersonalizationConsent"] as? String == "granted") + + superwall.adConsent = AdConsent(adUserData: .granted, adPersonalization: .denied) + + let updated = await dependencyContainer.makeSessionDeviceAttributes() + #expect(updated["adUserDataConsent"] as? String == "granted") + #expect(updated["adPersonalizationConsent"] as? String == "denied") + } + + @Test func eventTrackingBehaviorNone_reportsDenied() async { + let dependencyContainer = DependencyContainer(cache: CacheMock()) + let options = dependencyContainer.configManager.options + options.adConsent = AdConsent(adUserData: .granted, adPersonalization: .granted) + options.eventTrackingBehavior = .none + + let template = await dependencyContainer.deviceHelper.getTemplateDevice() + + #expect(template["adUserDataConsent"] as? String == "denied") + #expect(template["adPersonalizationConsent"] as? String == "denied") + // The developer's choice itself is left untouched. + #expect(options.adConsent.adUserData == .granted) + #expect(options.adConsent.adPersonalization == .granted) + } + + @Test(arguments: [EventTrackingBehavior.all, .superwallOnly]) + func otherTrackingBehaviors_reportTheChosenConsent(_ behavior: EventTrackingBehavior) { + let consent = AdConsent(adUserData: .denied, adPersonalization: .granted) + + let reported = consent.reported(for: behavior) + + #expect(reported.adUserData == .denied) + #expect(reported.adPersonalization == .granted) + } + + @Test func options_serializeAdConsentForConfigAttributes() { + let options = SuperwallOptions() + options.adConsent = AdConsent(adUserData: .denied, adPersonalization: .granted) + + let dictionary = options.toDictionary() + + #expect(dictionary["adUserDataConsent"] as? String == "denied") + #expect(dictionary["adPersonalizationConsent"] as? String == "granted") + } +} From 7e6e089205c6f8b200c7287c743ebb55f462ba41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:22:41 +0200 Subject: [PATCH 02/15] Make AdConsent immutable Its properties are now `let`, so consent only changes by assigning a new `AdConsent`, which always goes through the setter that re-sends device attributes. Matches the Android data class. Co-Authored-By: Claude Opus 5.5 --- Sources/SuperwallKit/Config/Options/AdConsent.swift | 8 +++++--- Sources/SuperwallKit/Superwall.swift | 3 +-- Tests/SuperwallKitTests/Config/AdConsentTests.swift | 6 ++++++ 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/Sources/SuperwallKit/Config/Options/AdConsent.swift b/Sources/SuperwallKit/Config/Options/AdConsent.swift index 9b8518caa7..d05898f58d 100644 --- a/Sources/SuperwallKit/Config/Options/AdConsent.swift +++ b/Sources/SuperwallKit/Config/Options/AdConsent.swift @@ -28,14 +28,16 @@ public enum ConsentStatus: Int, CustomStringConvertible, Sendable { /// /// Superwall passes this on to ad networks, such as Google Ads, when it reports /// conversions for your app. Both values default to ``ConsentStatus/granted``. +/// +/// It's immutable: to change consent, assign a new `AdConsent`. @objc(SWKAdConsent) @objcMembers public final class AdConsent: NSObject { /// Consent to send the user's data to ad networks for advertising. - public var adUserData: ConsentStatus + public let adUserData: ConsentStatus /// Consent to use the user's data for personalized advertising. - public var adPersonalization: ConsentStatus + public let adPersonalization: ConsentStatus public init( adUserData: ConsentStatus = .granted, @@ -56,6 +58,6 @@ public final class AdConsent: NSObject { if eventTrackingBehavior == .none { return AdConsent(adUserData: .denied, adPersonalization: .denied) } - return AdConsent(adUserData: adUserData, adPersonalization: adPersonalization) + return self } } diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index 8aab5835c1..03f4ae61e7 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -110,8 +110,7 @@ public final class Superwall: NSObject, ObservableObject { /// passes on to ad networks such as Google Ads when it reports conversions. /// /// Both values default to ``ConsentStatus/granted``. If your app has users in the - /// EEA, the UK or Switzerland, set this from your consent flow. Assign a new - /// ``AdConsent`` to send the change to Superwall straight away. + /// EEA, the UK or Switzerland, set this from your consent flow. /// /// You can also set the initial value via ``SuperwallOptions/adConsent`` /// before calling `configure`. diff --git a/Tests/SuperwallKitTests/Config/AdConsentTests.swift b/Tests/SuperwallKitTests/Config/AdConsentTests.swift index 60a49d52f2..134056cfa6 100644 --- a/Tests/SuperwallKitTests/Config/AdConsentTests.swift +++ b/Tests/SuperwallKitTests/Config/AdConsentTests.swift @@ -20,6 +20,12 @@ struct AdConsentTests { #expect(options.adConsent.adPersonalization == .granted) } + @Test func init_defaultsUnspecifiedPurposesToGranted() { + let consent = AdConsent(adUserData: .denied) + #expect(consent.adUserData == .denied) + #expect(consent.adPersonalization == .granted) + } + @Test func consentStatus_descriptionsMatchTheServerContract() { #expect(ConsentStatus.granted.description == "granted") #expect(ConsentStatus.denied.description == "denied") From 426667a69b4b96bc5607bb6ae69da57afea13a37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:26:16 +0200 Subject: [PATCH 03/15] Report ad personalization as denied when tracking isn't allowed `adPersonalizationConsent` is reported as "denied" when the ATT status is denied or restricted. An undetermined status (or no ATT at all) leaves it to the option, and `adUserDataConsent` ignores ATT. Applied when building device attributes, so the stored option and `config_attributes` keep the developer's values. The status is read through the existing `TrackingManagerProxy`, which never prompts and reports notDetermined when AppTrackingTransparency isn't linked. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- .../Config/Options/AdConsent.swift | 18 ++++- .../Config/Options/SuperwallOptions.swift | 3 +- .../Network/Device Helper/DeviceHelper.swift | 21 ++++- Sources/SuperwallKit/Superwall.swift | 3 +- .../Config/AdConsentTests.swift | 78 +++++++++++++++++-- 6 files changed, 111 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad516c7e36..61d0febe83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup - Adds the Customer Center, a self-service screen where users can view, restore, manage, cancel, refund and change their purchases, and contact support. Present it with `Superwall.shared.presentCustomerCenter()`, `CustomerCenterView` or `CustomerCenterViewController`, and configure it with `SuperwallOptions.customerCenter`. - Adds `CustomerCenterDelegate` and events for when the Customer Center opens, closes, and when users pick an action, answer a survey or request a refund. - Adds best-effort public IPv4 and IPv6 device attributes, with separate observation timestamps, for apps using the Superwall MMP. -- Adds `SuperwallOptions.adConsent` and `Superwall.shared.adConsent` to report a user's ad consent (`adUserData` and `adPersonalization`), which Superwall passes on to ad networks such as Google Ads when it reports conversions. Both default to granted. If your app has users in the EEA, the UK or Switzerland, set them from your consent flow. They're reported as the `adUserDataConsent` and `adPersonalizationConsent` device attributes, and as denied while `eventTrackingBehavior` is `.none`. +- Adds `SuperwallOptions.adConsent` and `Superwall.shared.adConsent` to report a user's ad consent (`adUserData` and `adPersonalization`), which Superwall passes on to ad networks such as Google Ads when it reports conversions. Both default to granted. If your app has users in the EEA, the UK or Switzerland, set them from your consent flow. They're reported as the `adUserDataConsent` and `adPersonalizationConsent` device attributes, and both as denied while `eventTrackingBehavior` is `.none`. Personalization is also reported as denied when the user has denied App Tracking Transparency or it's restricted. ### Fixes diff --git a/Sources/SuperwallKit/Config/Options/AdConsent.swift b/Sources/SuperwallKit/Config/Options/AdConsent.swift index d05898f58d..b31309223d 100644 --- a/Sources/SuperwallKit/Config/Options/AdConsent.swift +++ b/Sources/SuperwallKit/Config/Options/AdConsent.swift @@ -52,12 +52,22 @@ public final class AdConsent: NSObject { self.init(adUserData: .granted, adPersonalization: .granted) } - /// The consent to report, which is denied for both purposes while - /// `eventTrackingBehavior` is ``EventTrackingBehavior/none``. - func reported(for eventTrackingBehavior: EventTrackingBehavior) -> AdConsent { + /// The consent to report. Both purposes are denied while `eventTrackingBehavior` + /// is ``EventTrackingBehavior/none``, and personalization is denied when the user + /// hasn't allowed tracking (`attStatus` is restricted or denied). An undetermined + /// or missing ATT status leaves the developer's choice in place. + func reported( + for eventTrackingBehavior: EventTrackingBehavior, + attStatus: Int? + ) -> AdConsent { if eventTrackingBehavior == .none { return AdConsent(adUserData: .denied, adPersonalization: .denied) } - return self + switch attStatus.flatMap(FakeTrackingAuthorizationStatus.init(rawValue:)) { + case .restricted, .denied: + return AdConsent(adUserData: adUserData, adPersonalization: .denied) + case .notDetermined, .authorized, nil: + return self + } } } diff --git a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift index 359f9c19ef..08650a8c10 100644 --- a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift +++ b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift @@ -369,7 +369,8 @@ public final class SuperwallOptions: NSObject, Encodable { /// /// Both values default to ``ConsentStatus/granted``. If your app has users in the /// EEA, the UK or Switzerland, set this from your consent flow. Both are reported as - /// denied while ``eventTrackingBehavior`` is ``EventTrackingBehavior/none``. + /// denied while ``eventTrackingBehavior`` is ``EventTrackingBehavior/none``. On iOS, + /// personalization is reported as denied when the user hasn't allowed tracking. /// /// You can also change this at runtime via ``Superwall/adConsent``. public var adConsent = AdConsent() diff --git a/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift index afe561fbcb..0c8b0eada3 100644 --- a/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift +++ b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift @@ -15,6 +15,8 @@ import StoreKit class DeviceHelper { private let ipCollector: DeviceIPCollector + /// Reads the ATT status without prompting, or `nil` where the OS has no such concept. + private let attStatusProvider: () -> Int? var localeIdentifier: String { let localeIdentifier = factory.makeLocaleIdentifier() return localeIdentifier ?? Locale.autoupdatingCurrent.identifier @@ -930,8 +932,10 @@ class DeviceHelper { & ConfigStateFactory & OptionsFactory, ipCollector: DeviceIPCollector? = nil, - isUIKitReadSafe: @escaping () -> Bool = { DeviceHelper.isUIKitReadSafe } + isUIKitReadSafe: @escaping () -> Bool = { DeviceHelper.isUIKitReadSafe }, + attStatusProvider: @escaping () -> Int? = { DeviceHelper.attStatus } ) { + self.attStatusProvider = attStatusProvider self.ipCollector = ipCollector ?? DeviceIPCollector( ipV4Url: api.enrichment.ipV4Url, ipV6Url: api.enrichment.ipV6Url @@ -955,6 +959,16 @@ class DeviceHelper { registerForTraitChanges() } + /// The ATT authorization status, or `nil` where the OS has no such concept. + /// Returns `notDetermined` when AppTrackingTransparency isn't linked. + static var attStatus: Int? { + #if os(iOS) || targetEnvironment(macCatalyst) || os(macOS) || os(visionOS) + return TrackingManagerProxy().trackingAuthorizationStatus() + #else + return nil + #endif + } + deinit { for observer in traitObservers { NotificationCenter.default.removeObserver(observer) @@ -1015,7 +1029,10 @@ class DeviceHelper { // the same way by hand — keep them in step. let traits = currentUITraits let options = factory.makeSuperwallOptions() - let adConsent = options.adConsent.reported(for: options.eventTrackingBehavior) + let adConsent = options.adConsent.reported( + for: options.eventTrackingBehavior, + attStatus: attStatusProvider() + ) let template = DeviceTemplate( publicApiKey: storage.apiKey, diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index 03f4ae61e7..87f9ddf142 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -110,7 +110,8 @@ public final class Superwall: NSObject, ObservableObject { /// passes on to ad networks such as Google Ads when it reports conversions. /// /// Both values default to ``ConsentStatus/granted``. If your app has users in the - /// EEA, the UK or Switzerland, set this from your consent flow. + /// EEA, the UK or Switzerland, set this from your consent flow. On iOS, + /// personalization is reported as denied when the user hasn't allowed tracking. /// /// You can also set the initial value via ``SuperwallOptions/adConsent`` /// before calling `configure`. diff --git a/Tests/SuperwallKitTests/Config/AdConsentTests.swift b/Tests/SuperwallKitTests/Config/AdConsentTests.swift index 134056cfa6..ad894f170a 100644 --- a/Tests/SuperwallKitTests/Config/AdConsentTests.swift +++ b/Tests/SuperwallKitTests/Config/AdConsentTests.swift @@ -33,8 +33,9 @@ struct AdConsentTests { @Test func templateDevice_reportsGrantedByDefault() async { let dependencyContainer = DependencyContainer(cache: CacheMock()) + let deviceHelper = makeDeviceHelper(dependencyContainer, attStatus: .notDetermined) - let template = await dependencyContainer.deviceHelper.getTemplateDevice() + let template = await deviceHelper.getTemplateDevice() #expect(template["adUserDataConsent"] as? String == "granted") #expect(template["adPersonalizationConsent"] as? String == "granted") @@ -43,28 +44,30 @@ struct AdConsentTests { @Test func settingAdConsentAtRuntime_isReflectedInDeviceAttributes() async { let dependencyContainer = DependencyContainer(cache: CacheMock()) let superwall = Superwall(dependencyContainer: dependencyContainer) + let deviceHelper = makeDeviceHelper(dependencyContainer, attStatus: .authorized) superwall.adConsent = AdConsent(adUserData: .denied, adPersonalization: .granted) #expect(dependencyContainer.configManager.options.adConsent.adUserData == .denied) - let template = await dependencyContainer.deviceHelper.getTemplateDevice() + let template = await deviceHelper.getTemplateDevice() #expect(template["adUserDataConsent"] as? String == "denied") #expect(template["adPersonalizationConsent"] as? String == "granted") superwall.adConsent = AdConsent(adUserData: .granted, adPersonalization: .denied) - let updated = await dependencyContainer.makeSessionDeviceAttributes() + let updated = await deviceHelper.getTemplateDevice() #expect(updated["adUserDataConsent"] as? String == "granted") #expect(updated["adPersonalizationConsent"] as? String == "denied") } @Test func eventTrackingBehaviorNone_reportsDenied() async { let dependencyContainer = DependencyContainer(cache: CacheMock()) + let deviceHelper = makeDeviceHelper(dependencyContainer, attStatus: .authorized) let options = dependencyContainer.configManager.options options.adConsent = AdConsent(adUserData: .granted, adPersonalization: .granted) options.eventTrackingBehavior = .none - let template = await dependencyContainer.deviceHelper.getTemplateDevice() + let template = await deviceHelper.getTemplateDevice() #expect(template["adUserDataConsent"] as? String == "denied") #expect(template["adPersonalizationConsent"] as? String == "denied") @@ -73,11 +76,58 @@ struct AdConsentTests { #expect(options.adConsent.adPersonalization == .granted) } + @Test(arguments: [FakeTrackingAuthorizationStatus.denied, .restricted]) + func attNotAllowed_deniesPersonalizationOnly(_ attStatus: FakeTrackingAuthorizationStatus) async { + let dependencyContainer = DependencyContainer(cache: CacheMock()) + let deviceHelper = makeDeviceHelper(dependencyContainer, attStatus: attStatus) + let options = dependencyContainer.configManager.options + options.adConsent = AdConsent(adUserData: .granted, adPersonalization: .granted) + + let template = await deviceHelper.getTemplateDevice() + + #expect(template["adUserDataConsent"] as? String == "granted") + #expect(template["adPersonalizationConsent"] as? String == "denied") + // The stored option, which `config_attributes` reports, is left untouched. + #expect(options.adConsent.adPersonalization == .granted) + #expect(options.toDictionary()["adPersonalizationConsent"] as? String == "granted") + } + + @Test(arguments: [FakeTrackingAuthorizationStatus.notDetermined, .authorized]) + func attUndeterminedOrAuthorized_reportsTheChosenConsent( + _ attStatus: FakeTrackingAuthorizationStatus + ) async { + let dependencyContainer = DependencyContainer(cache: CacheMock()) + let deviceHelper = makeDeviceHelper(dependencyContainer, attStatus: attStatus) + let options = dependencyContainer.configManager.options + + options.adConsent = AdConsent(adUserData: .denied, adPersonalization: .granted) + let granted = await deviceHelper.getTemplateDevice() + #expect(granted["adUserDataConsent"] as? String == "denied") + #expect(granted["adPersonalizationConsent"] as? String == "granted") + + options.adConsent = AdConsent(adUserData: .granted, adPersonalization: .denied) + let denied = await deviceHelper.getTemplateDevice() + #expect(denied["adUserDataConsent"] as? String == "granted") + #expect(denied["adPersonalizationConsent"] as? String == "denied") + } + + @Test func attDenied_withTrackingNone_deniesBoth() { + let consent = AdConsent(adUserData: .granted, adPersonalization: .granted) + + let reported = consent.reported( + for: .none, + attStatus: FakeTrackingAuthorizationStatus.denied.rawValue + ) + + #expect(reported.adUserData == .denied) + #expect(reported.adPersonalization == .denied) + } + @Test(arguments: [EventTrackingBehavior.all, .superwallOnly]) func otherTrackingBehaviors_reportTheChosenConsent(_ behavior: EventTrackingBehavior) { let consent = AdConsent(adUserData: .denied, adPersonalization: .granted) - let reported = consent.reported(for: behavior) + let reported = consent.reported(for: behavior, attStatus: nil) #expect(reported.adUserData == .denied) #expect(reported.adPersonalization == .granted) @@ -92,4 +142,22 @@ struct AdConsentTests { #expect(dictionary["adUserDataConsent"] as? String == "denied") #expect(dictionary["adPersonalizationConsent"] as? String == "granted") } + + /// A helper whose ATT status is fixed, since the simulator's real status + /// can't be set from a test. The container is held by the caller because the + /// helper only keeps its factory `unowned`. + private func makeDeviceHelper( + _ dependencyContainer: DependencyContainer, + attStatus: FakeTrackingAuthorizationStatus + ) -> DeviceHelper { + return DeviceHelper( + api: dependencyContainer.api, + storage: dependencyContainer.storage, + network: dependencyContainer.network, + entitlementsInfo: dependencyContainer.entitlementsInfo, + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + attStatusProvider: { attStatus.rawValue } + ) + } } From 9747972a01b14c7ac341b8b1e84099423ba38a80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:34:52 +0200 Subject: [PATCH 04/15] Republish device attributes when ATT changes ad personalization If device attributes went out while ATT was undetermined and the user then denied tracking in the same session, the server kept "granted" personalization until the next session. The SDK now remembers the `adPersonalizationConsent` it last sent and, on app activation and after its own tracking-permission request, re-sends device attributes once if the value it would report has changed. It never prompts or polls. Co-Authored-By: Claude Opus 5.5 --- .../App Session/AppSessionManager.swift | 10 +- .../Internal Tracking/Tracking.swift | 6 + .../Network/Device Helper/DeviceHelper.swift | 48 +++++- .../PaywallMessageHandler.swift | 3 + Sources/SuperwallKit/Superwall.swift | 13 ++ .../Config/AdConsentTests.swift | 140 ++++++++++++++++++ 6 files changed, 214 insertions(+), 6 deletions(-) diff --git a/Sources/SuperwallKit/Analytics/App Session/AppSessionManager.swift b/Sources/SuperwallKit/Analytics/App Session/AppSessionManager.swift index 688efcd9f7..16c6f110d5 100644 --- a/Sources/SuperwallKit/Analytics/App Session/AppSessionManager.swift +++ b/Sources/SuperwallKit/Analytics/App Session/AppSessionManager.swift @@ -19,13 +19,19 @@ class AppSessionManager { private unowned let configManager: ConfigManager private unowned let storage: Storage private unowned let delegate: DeviceHelperFactory & UserAttributesPlacementFactory + /// Catches ATT changes made while the app was in the background, e.g. in Settings. + private let republishIfAdConsentChanged: () async -> Void init( configManager: ConfigManager, identityManager: IdentityManager, storage: Storage, - delegate: DeviceHelperFactory & UserAttributesPlacementFactory + delegate: DeviceHelperFactory & UserAttributesPlacementFactory, + republishIfAdConsentChanged: @escaping () async -> Void = { + await Superwall.shared.republishDeviceAttributesIfAdConsentChanged() + } ) { + self.republishIfAdConsentChanged = republishIfAdConsentChanged self.configManager = configManager self.storage = storage self.delegate = delegate @@ -97,6 +103,8 @@ class AppSessionManager { Task { await Superwall.shared.track(InternalSuperwallEvent.AppOpen()) await sessionCouldRefresh() + // After the session check, which may already have sent fresh device attributes. + await republishIfAdConsentChanged() } } diff --git a/Sources/SuperwallKit/Analytics/Internal Tracking/Tracking.swift b/Sources/SuperwallKit/Analytics/Internal Tracking/Tracking.swift index 30e2b0154a..f2cd2347a0 100644 --- a/Sources/SuperwallKit/Analytics/Internal Tracking/Tracking.swift +++ b/Sources/SuperwallKit/Analytics/Internal Tracking/Tracking.swift @@ -64,6 +64,12 @@ extension Superwall { } dependencyContainer.storage.coreDataManager.savePlacementData(eventData) + if let deviceAttributes = event as? InternalSuperwallEvent.DeviceAttributes { + dependencyContainer.deviceHelper.recordPublishedDeviceAttributes( + deviceAttributes.deviceAttributes + ) + } + if event.canImplicitlyTriggerPaywall { Task.detached { [weak self] in await self?.handleImplicitTrigger( diff --git a/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift index 0c8b0eada3..e99c130973 100644 --- a/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift +++ b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift @@ -17,6 +17,10 @@ class DeviceHelper { private let ipCollector: DeviceIPCollector /// Reads the ATT status without prompting, or `nil` where the OS has no such concept. private let attStatusProvider: () -> Int? + /// The `adPersonalizationConsent` in the last device attributes sent, or `nil` + /// before the first send. Guarded by `adConsentLock`. + private var publishedAdPersonalizationConsent: String? + private let adConsentLock = NSLock() var localeIdentifier: String { let localeIdentifier = factory.makeLocaleIdentifier() return localeIdentifier ?? Locale.autoupdatingCurrent.identifier @@ -969,6 +973,44 @@ class DeviceHelper { #endif } + /// The ad consent device attributes would report right now. + var reportedAdConsent: AdConsent { + let options = factory.makeSuperwallOptions() + return options.adConsent.reported( + for: options.eventTrackingBehavior, + attStatus: attStatusProvider() + ) + } + + /// Notes the ad personalization consent in device attributes that were just sent. + func recordPublishedDeviceAttributes(_ attributes: [String: Any]) { + guard let consent = attributes["adPersonalizationConsent"] as? String else { + return + } + adConsentLock.lock() + publishedAdPersonalizationConsent = consent + adConsentLock.unlock() + } + + /// Whether the ad personalization consent has changed since device attributes + /// were last sent, for example because the user answered the ATT prompt. When it + /// has, it's marked as sent so concurrent callers republish only once. + /// + /// Returns `false` before the first send, which reports the current value anyway. + func claimAdPersonalizationConsentRepublish() -> Bool { + let current = reportedAdConsent.adPersonalization.description + adConsentLock.lock() + defer { adConsentLock.unlock() } + guard + let published = publishedAdPersonalizationConsent, + published != current + else { + return false + } + publishedAdPersonalizationConsent = current + return true + } + deinit { for observer in traitObservers { NotificationCenter.default.removeObserver(observer) @@ -1028,11 +1070,7 @@ class DeviceHelper { // inline rather than going through ``interfaceStyle``, so the two resolve it // the same way by hand — keep them in step. let traits = currentUITraits - let options = factory.makeSuperwallOptions() - let adConsent = options.adConsent.reported( - for: options.eventTrackingBehavior, - attStatus: attStatusProvider() - ) + let adConsent = reportedAdConsent let template = DeviceTemplate( publicApiKey: storage.apiKey, 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 ec15c5d491..33e20a0b16 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 @@ -677,6 +677,9 @@ final class PaywallMessageHandler: WebEventDelegate { await Superwall.shared.track(requestedEvent) let status = await permissionHandler.requestPermission(permissionType) + if permissionType == .tracking { + await Superwall.shared.republishDeviceAttributesIfAdConsentChanged() + } // Track permission result event let resultState: InternalSuperwallEvent.PermissionState = diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index 87f9ddf142..a8e72afbfe 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -1013,6 +1013,19 @@ public final class Superwall: NSObject, ObservableObject { } } + /// Re-sends device attributes if the ad personalization consent they report has + /// changed since they were last sent, such as after the user answers the ATT + /// prompt mid-session. Never prompts. Returns whether it sent them. + @discardableResult + func republishDeviceAttributesIfAdConsentChanged() async -> Bool { + guard dependencyContainer.deviceHelper.claimAdPersonalizationConsentRepublish() else { + return false + } + let deviceAttributes = await dependencyContainer.makeSessionDeviceAttributes() + await track(InternalSuperwallEvent.DeviceAttributes(deviceAttributes: deviceAttributes)) + return true + } + /// Sets the user interface style, which overrides the system setting. Set to `nil` to revert /// back to using the system setting. public func setInterfaceStyle(to interfaceStyle: InterfaceStyle?) { diff --git a/Tests/SuperwallKitTests/Config/AdConsentTests.swift b/Tests/SuperwallKitTests/Config/AdConsentTests.swift index ad894f170a..ae431e0f97 100644 --- a/Tests/SuperwallKitTests/Config/AdConsentTests.swift +++ b/Tests/SuperwallKitTests/Config/AdConsentTests.swift @@ -8,6 +8,7 @@ @testable import SuperwallKit import Testing import Foundation +import UIKit struct AdConsentTests { @Test func defaults_areGranted() { @@ -39,6 +40,7 @@ struct AdConsentTests { #expect(template["adUserDataConsent"] as? String == "granted") #expect(template["adPersonalizationConsent"] as? String == "granted") + withExtendedLifetime(dependencyContainer) {} } @Test func settingAdConsentAtRuntime_isReflectedInDeviceAttributes() async { @@ -133,6 +135,84 @@ struct AdConsentTests { #expect(reported.adPersonalization == .granted) } + // MARK: - Republishing on ATT changes + + @Test func attChange_republishesDeviceAttributesOnce() async { + let attStatus = ATTStatusBox(.notDetermined) + let (superwall, dependencyContainer, recorder) = makeSuperwall(attStatus: attStatus) + + // The initial upload reports the option, since ATT hasn't been asked yet. + let initial = await dependencyContainer.makeSessionDeviceAttributes() + await superwall.track(InternalSuperwallEvent.DeviceAttributes(deviceAttributes: initial)) + #expect(recorder.sent.count == 1) + #expect(recorder.sent.last?["adPersonalizationConsent"] as? String == "granted") + + // Unchanged: nothing to send. + #expect(await superwall.republishDeviceAttributesIfAdConsentChanged() == false) + #expect(recorder.sent.count == 1) + + // The user denies the ATT prompt mid-session. + attStatus.value = .denied + #expect(await superwall.republishDeviceAttributesIfAdConsentChanged() == true) + #expect(recorder.sent.count == 2) + #expect(recorder.sent.last?["adPersonalizationConsent"] as? String == "denied") + #expect(recorder.sent.last?["adUserDataConsent"] as? String == "granted") + + // Already reflected: no second republish. + #expect(await superwall.republishDeviceAttributesIfAdConsentChanged() == false) + #expect(recorder.sent.count == 2) + } + + @Test func attChange_beforeAnyUpload_doesNotRepublish() async { + let attStatus = ATTStatusBox(.notDetermined) + let (superwall, _, recorder) = makeSuperwall(attStatus: attStatus) + + attStatus.value = .denied + + // The first upload reports the current value, so there's nothing stale yet. + #expect(await superwall.republishDeviceAttributesIfAdConsentChanged() == false) + #expect(recorder.sent.isEmpty) + } + + @Test func appActivation_republishesOnlyWhenATTChanged() async { + let attStatus = ATTStatusBox(.notDetermined) + let (superwall, dependencyContainer, recorder) = makeSuperwall(attStatus: attStatus) + // `AppSessionManager` holds its delegate `unowned`. + let sessionDelegate = AppManagerDelegateMock() + let appSessionManager = AppSessionManager( + configManager: dependencyContainer.configManager, + identityManager: dependencyContainer.identityManager, + storage: dependencyContainer.storage, + delegate: sessionDelegate, + republishIfAdConsentChanged: { + await superwall.republishDeviceAttributesIfAdConsentChanged() + } + ) + // Let the manager register its observers. + try? await Task.sleep(nanoseconds: 100_000_000) + + let initial = await dependencyContainer.makeSessionDeviceAttributes() + await superwall.track(InternalSuperwallEvent.DeviceAttributes(deviceAttributes: initial)) + + // Activation without an ATT change sends nothing. + await NotificationCenter.default.post( + Notification(name: UIApplication.didBecomeActiveNotification) + ) + try? await Task.sleep(nanoseconds: 300_000_000) + #expect(recorder.sent.count == 1) + + // Activation after the user denied tracking sends exactly one update. + attStatus.value = .denied + await NotificationCenter.default.post( + Notification(name: UIApplication.didBecomeActiveNotification) + ) + await waitUntil { recorder.sent.count >= 2 } + try? await Task.sleep(nanoseconds: 300_000_000) + #expect(recorder.sent.count == 2) + #expect(recorder.sent.last?["adPersonalizationConsent"] as? String == "denied") + withExtendedLifetime((appSessionManager, sessionDelegate, dependencyContainer)) {} + } + @Test func options_serializeAdConsentForConfigAttributes() { let options = SuperwallOptions() options.adConsent = AdConsent(adUserData: .denied, adPersonalization: .granted) @@ -160,4 +240,64 @@ struct AdConsentTests { attStatusProvider: { attStatus.rawValue } ) } + + /// A `Superwall` whose device helper reads `attStatus`, with a delegate that + /// records every device-attributes event it sends. + private func makeSuperwall( + attStatus: ATTStatusBox + ) -> (Superwall, DependencyContainer, DeviceAttributesRecorder) { + let dependencyContainer = DependencyContainer(cache: CacheMock()) + dependencyContainer.deviceHelper = DeviceHelper( + api: dependencyContainer.api, + storage: dependencyContainer.storage, + network: dependencyContainer.network, + entitlementsInfo: dependencyContainer.entitlementsInfo, + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + attStatusProvider: { attStatus.value.rawValue } + ) + let superwall = Superwall(dependencyContainer: dependencyContainer) + let recorder = DeviceAttributesRecorder() + dependencyContainer.delegateAdapter.swiftDelegate = recorder + return (superwall, dependencyContainer, recorder) + } + + private func waitUntil( + timeout: TimeInterval = 2, + _ condition: @escaping () -> Bool + ) async { + let start = Date() + while !condition() && Date().timeIntervalSince(start) < timeout { + try? await Task.sleep(nanoseconds: 50_000_000) + } + } +} + +private final class ATTStatusBox: @unchecked Sendable { + private let lock = NSLock() + private var _value: FakeTrackingAuthorizationStatus + + init(_ value: FakeTrackingAuthorizationStatus) { + _value = value + } + + var value: FakeTrackingAuthorizationStatus { + get { lock.withLock { _value } } + set { lock.withLock { _value = newValue } } + } +} + +private final class DeviceAttributesRecorder: SuperwallDelegate, @unchecked Sendable { + private let lock = NSLock() + private var _sent: [[String: Any]] = [] + + var sent: [[String: Any]] { + lock.withLock { _sent } + } + + func handleSuperwallEvent(withInfo eventInfo: SuperwallEventInfo) { + if case let .deviceAttributes(attributes) = eventInfo.event { + lock.withLock { _sent.append(attributes) } + } + } } From 53a0e23557042b9b9c2a3f192580748dfd4cab54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:37:54 +0200 Subject: [PATCH 05/15] Rename to AdConsentStatus and serialize ad consent updates - Rename `ConsentStatus` to `AdConsentStatus` so it doesn't clash with FirebaseAnalytics' `ConsentStatus`. The Objective-C name stays `SWKConsentStatus`. - Send `adConsent` updates through one serial queue with a generation number, dropping a superseded snapshot before it's tracked, so the last assignment always wins. The ATT republish uses the same queue. - Test that the setter tracks `config_attributes` and `device_attributes` with the new values, and that the last of two rapid assignments wins. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- .../Config/Options/AdConsent.swift | 12 ++-- .../Config/Options/AdConsentUpdateQueue.swift | 58 ++++++++++++++++ .../Config/Options/SuperwallOptions.swift | 2 +- Sources/SuperwallKit/Superwall.swift | 44 +++++++++--- SuperwallKit.xcodeproj/project.pbxproj | 4 ++ .../Config/AdConsentTests.swift | 67 +++++++++++++++---- .../Web/MockSuperwallDelegate.swift | 2 + 8 files changed, 161 insertions(+), 30 deletions(-) create mode 100644 Sources/SuperwallKit/Config/Options/AdConsentUpdateQueue.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 61d0febe83..61671133ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup - Adds the Customer Center, a self-service screen where users can view, restore, manage, cancel, refund and change their purchases, and contact support. Present it with `Superwall.shared.presentCustomerCenter()`, `CustomerCenterView` or `CustomerCenterViewController`, and configure it with `SuperwallOptions.customerCenter`. - Adds `CustomerCenterDelegate` and events for when the Customer Center opens, closes, and when users pick an action, answer a survey or request a refund. - Adds best-effort public IPv4 and IPv6 device attributes, with separate observation timestamps, for apps using the Superwall MMP. -- Adds `SuperwallOptions.adConsent` and `Superwall.shared.adConsent` to report a user's ad consent (`adUserData` and `adPersonalization`), which Superwall passes on to ad networks such as Google Ads when it reports conversions. Both default to granted. If your app has users in the EEA, the UK or Switzerland, set them from your consent flow. They're reported as the `adUserDataConsent` and `adPersonalizationConsent` device attributes, and both as denied while `eventTrackingBehavior` is `.none`. Personalization is also reported as denied when the user has denied App Tracking Transparency or it's restricted. +- Adds `SuperwallOptions.adConsent` and `Superwall.shared.adConsent` to report a user's ad consent (`adUserData` and `adPersonalization`, each an `AdConsentStatus` of `.granted` or `.denied`), which Superwall passes on to ad networks such as Google Ads when it reports conversions. Both default to granted. If your app has users in the EEA, the UK or Switzerland, set them from your consent flow. They're reported as the `adUserDataConsent` and `adPersonalizationConsent` device attributes, and both as denied while `eventTrackingBehavior` is `.none`. Personalization is also reported as denied when the user has denied App Tracking Transparency or it's restricted. ### Fixes diff --git a/Sources/SuperwallKit/Config/Options/AdConsent.swift b/Sources/SuperwallKit/Config/Options/AdConsent.swift index b31309223d..ccfaae4a85 100644 --- a/Sources/SuperwallKit/Config/Options/AdConsent.swift +++ b/Sources/SuperwallKit/Config/Options/AdConsent.swift @@ -9,7 +9,7 @@ import Foundation /// Whether a user has granted or denied consent for an advertising purpose. @objc(SWKConsentStatus) -public enum ConsentStatus: Int, CustomStringConvertible, Sendable { +public enum AdConsentStatus: Int, CustomStringConvertible, Sendable { /// The user has granted consent. This is the default. case granted = 0 @@ -27,21 +27,21 @@ public enum ConsentStatus: Int, CustomStringConvertible, Sendable { /// A user's consent for how their data is used for advertising. /// /// Superwall passes this on to ad networks, such as Google Ads, when it reports -/// conversions for your app. Both values default to ``ConsentStatus/granted``. +/// conversions for your app. Both values default to ``AdConsentStatus/granted``. /// /// It's immutable: to change consent, assign a new `AdConsent`. @objc(SWKAdConsent) @objcMembers public final class AdConsent: NSObject { /// Consent to send the user's data to ad networks for advertising. - public let adUserData: ConsentStatus + public let adUserData: AdConsentStatus /// Consent to use the user's data for personalized advertising. - public let adPersonalization: ConsentStatus + public let adPersonalization: AdConsentStatus public init( - adUserData: ConsentStatus = .granted, - adPersonalization: ConsentStatus = .granted + adUserData: AdConsentStatus = .granted, + adPersonalization: AdConsentStatus = .granted ) { self.adUserData = adUserData self.adPersonalization = adPersonalization diff --git a/Sources/SuperwallKit/Config/Options/AdConsentUpdateQueue.swift b/Sources/SuperwallKit/Config/Options/AdConsentUpdateQueue.swift new file mode 100644 index 0000000000..bb63adf763 --- /dev/null +++ b/Sources/SuperwallKit/Config/Options/AdConsentUpdateQueue.swift @@ -0,0 +1,58 @@ +// +// AdConsentUpdateQueue.swift +// +// +// Created by Yusuf Tör on 08/10/2026. +// + +import Foundation + +/// Sends ad consent updates one at a time, in the order they were made, so an older +/// snapshot can never be tracked after a newer one. +/// +/// Each ``Superwall/adConsent`` assignment starts a new generation. A job checks +/// that its generation is still current before tracking, so a superseded update is +/// dropped in favour of the newer one queued behind it. +final class AdConsentUpdateQueue: @unchecked Sendable { + typealias Job = @Sendable () async -> Void + + private let continuation: AsyncStream.Continuation + private let lock = NSLock() + private var generation = 0 + + init() { + let (stream, continuation) = AsyncStream.makeStream(of: Job.self) + self.continuation = continuation + Task { + for await job in stream { + await job() + } + } + } + + deinit { + continuation.finish() + } + + /// The generation of the latest assignment. + var currentGeneration: Int { + lock.withLock { generation } + } + + /// Starts a new generation, making every earlier one stale. + func nextGeneration() -> Int { + lock.withLock { + generation += 1 + return generation + } + } + + func isCurrent(_ generation: Int) -> Bool { + lock.withLock { self.generation == generation } + } + + /// Runs `job` after every job enqueued before it has finished. + func enqueue(_ job: @escaping Job) { + continuation.yield(job) + } +} diff --git a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift index 08650a8c10..e6ff4f2599 100644 --- a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift +++ b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift @@ -367,7 +367,7 @@ public final class SuperwallOptions: NSObject, Encodable { /// The user's consent for how their data is used for advertising, which Superwall /// passes on to ad networks such as Google Ads when it reports conversions. /// - /// Both values default to ``ConsentStatus/granted``. If your app has users in the + /// Both values default to ``AdConsentStatus/granted``. If your app has users in the /// EEA, the UK or Switzerland, set this from your consent flow. Both are reported as /// denied while ``eventTrackingBehavior`` is ``EventTrackingBehavior/none``. On iOS, /// personalization is reported as denied when the user hasn't allowed tracking. diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index a8e72afbfe..89f8c7a478 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -109,7 +109,7 @@ public final class Superwall: NSObject, ObservableObject { /// The user's consent for how their data is used for advertising, which Superwall /// passes on to ad networks such as Google Ads when it reports conversions. /// - /// Both values default to ``ConsentStatus/granted``. If your app has users in the + /// Both values default to ``AdConsentStatus/granted``. If your app has users in the /// EEA, the UK or Switzerland, set this from your consent flow. On iOS, /// personalization is reported as denied when the user hasn't allowed tracking. /// @@ -122,11 +122,19 @@ public final class Superwall: NSObject, ObservableObject { set { options.adConsent = newValue + let generation = adConsentUpdates.nextGeneration() let configAttributes = dependencyContainer.makeConfigAttributes() - Task { - await track(configAttributes) - let deviceAttributes = await dependencyContainer.makeSessionDeviceAttributes() - await track(InternalSuperwallEvent.DeviceAttributes(deviceAttributes: deviceAttributes)) + adConsentUpdates.enqueue { [weak self] in + guard let self, self.adConsentUpdates.isCurrent(generation) else { + return + } + await self.track(configAttributes) + let deviceAttributes = await self.dependencyContainer.makeSessionDeviceAttributes() + // A newer assignment, queued behind this one, sends its own snapshot. + guard self.adConsentUpdates.isCurrent(generation) else { + return + } + await self.track(InternalSuperwallEvent.DeviceAttributes(deviceAttributes: deviceAttributes)) } } } @@ -450,6 +458,9 @@ public final class Superwall: NSObject, ObservableObject { /// Items involved in the presentation of paywalls. let presentationItems = PresentationItems() + /// Serializes the device attribute updates that report ad consent. + let adConsentUpdates = AdConsentUpdateQueue() + /// Determines whether a paywall is being presented. public var isPaywallPresented: Bool { paywallViewController != nil @@ -1017,13 +1028,26 @@ public final class Superwall: NSObject, ObservableObject { /// changed since they were last sent, such as after the user answers the ATT /// prompt mid-session. Never prompts. Returns whether it sent them. @discardableResult + /// + /// Goes through the same queue as ``adConsent`` assignments, so it can't overtake + /// one. If an assignment is made while it waits, it defers to that. func republishDeviceAttributesIfAdConsentChanged() async -> Bool { - guard dependencyContainer.deviceHelper.claimAdPersonalizationConsentRepublish() else { - return false + let generation = adConsentUpdates.currentGeneration + return await withCheckedContinuation { continuation in + adConsentUpdates.enqueue { [weak self] in + guard + let self, + self.adConsentUpdates.isCurrent(generation), + self.dependencyContainer.deviceHelper.claimAdPersonalizationConsentRepublish() + else { + continuation.resume(returning: false) + return + } + let deviceAttributes = await self.dependencyContainer.makeSessionDeviceAttributes() + await self.track(InternalSuperwallEvent.DeviceAttributes(deviceAttributes: deviceAttributes)) + continuation.resume(returning: true) + } } - let deviceAttributes = await dependencyContainer.makeSessionDeviceAttributes() - await track(InternalSuperwallEvent.DeviceAttributes(deviceAttributes: deviceAttributes)) - return true } /// Sets the user interface style, which overrides the system setting. Set to `nil` to revert diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index 76c0800ff1..96bd2d24dd 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -53,6 +53,7 @@ 1225B991B40D16B7FB4EF1A5 /* EvaluateRulesOperatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16AC8D761A7F5A7F012EA39B /* EvaluateRulesOperatorTests.swift */; }; 12A2722FD932939699923E60 /* V2ProductsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 937CAD765EA00E4A0FC86037 /* V2ProductsResponse.swift */; }; 12D25E5674DF81B033C9659E /* FreeTrialTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7553D295A9E169B45FAC1477 /* FreeTrialTemplate.swift */; }; + 1348E742C928F542FCCC86EE /* AdConsentUpdateQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6F00A06AEE73D087036B81C /* AdConsentUpdateQueue.swift */; }; 1386E66C93D643CAB36FA920 /* Product.swift in Sources */ = {isa = PBXBuildFile; fileRef = D38D3A69A26709BFB52C6A3A /* Product.swift */; }; 14B9EC6E8BAE199C9349838C /* Enrichment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68363EE16B2DE5C1C5361657 /* Enrichment.swift */; }; 14D4EA6EAA43647D24A9716A /* AlertControllerFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEE0510BC2F7917AA57B896D /* AlertControllerFactory.swift */; }; @@ -1179,6 +1180,7 @@ A69194A3AABBE56CB18177F1 /* CustomerCenterDelegateAdapterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerCenterDelegateAdapterTests.swift; sourceTree = ""; }; A6B47DD5F59411CC529CD2DB /* pt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pt; path = pt.lproj/Localizable.strings; sourceTree = ""; }; A6BCA6546821A143D0087CD9 /* CustomerCenterConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerCenterConfigurationTests.swift; sourceTree = ""; }; + A6F00A06AEE73D087036B81C /* AdConsentUpdateQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdConsentUpdateQueue.swift; sourceTree = ""; }; A78C5C57C3C92444EBAC2E38 /* TrackingManagerProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackingManagerProxy.swift; sourceTree = ""; }; A79E9DBDDA7FEE63C15FBEAF /* CELEvaluatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CELEvaluatorTests.swift; sourceTree = ""; }; A7A8FDBB0F8D450288C3FEA0 /* LocalFileSchemeHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalFileSchemeHandlerTests.swift; sourceTree = ""; }; @@ -3476,6 +3478,7 @@ isa = PBXGroup; children = ( DE25827D6C0FFD2E3AEB5E1A /* AdConsent.swift */, + A6F00A06AEE73D087036B81C /* AdConsentUpdateQueue.swift */, 23307BBFD80385233DDD4C43 /* AssetResource.swift */, 93D8033AAF5549E30ACDA3EA /* EventTrackingBehavior.swift */, B1A64CCBCB23CC1715DF79AC /* PaywallOptions.swift */, @@ -3967,6 +3970,7 @@ AC13B2D29FF61BDC995132DD /* AccountDetailsSection.swift in Sources */, 5E05FDE4F45BD5B0DF6AFB9F /* ActivityIndicatorView.swift in Sources */, 2200A6F9DE53E95064A17A20 /* AdConsent.swift in Sources */, + 1348E742C928F542FCCC86EE /* AdConsentUpdateQueue.swift in Sources */, AECD80682E1909735CCDAA78 /* AdServicesAttributionAttempts.swift in Sources */, ED575DD46B84EE351972AC6B /* AdServicesResponse.swift in Sources */, 90EE0190126A3BBA4661122E /* AddPaywallProducts.swift in Sources */, diff --git a/Tests/SuperwallKitTests/Config/AdConsentTests.swift b/Tests/SuperwallKitTests/Config/AdConsentTests.swift index ae431e0f97..31388ddb4d 100644 --- a/Tests/SuperwallKitTests/Config/AdConsentTests.swift +++ b/Tests/SuperwallKitTests/Config/AdConsentTests.swift @@ -28,8 +28,8 @@ struct AdConsentTests { } @Test func consentStatus_descriptionsMatchTheServerContract() { - #expect(ConsentStatus.granted.description == "granted") - #expect(ConsentStatus.denied.description == "denied") + #expect(AdConsentStatus.granted.description == "granted") + #expect(AdConsentStatus.denied.description == "denied") } @Test func templateDevice_reportsGrantedByDefault() async { @@ -135,6 +135,42 @@ struct AdConsentTests { #expect(reported.adPersonalization == .granted) } + // MARK: - Runtime updates + + @Test func settingAdConsent_tracksConfigAndDeviceAttributes() async { + let (superwall, _, recorder) = makeSuperwall(attStatus: ATTStatusBox(.authorized)) + + superwall.adConsent = AdConsent(adUserData: .denied, adPersonalization: .denied) + + await waitUntil { !recorder.sent.isEmpty } + #expect(recorder.sentConfigAttributes.count == 1) + #expect(recorder.sentConfigAttributes.last?["adUserDataConsent"] as? String == "denied") + #expect( + recorder.sentConfigAttributes.last?["adPersonalizationConsent"] as? String == "denied" + ) + #expect(recorder.sent.count == 1) + #expect(recorder.sent.last?["adUserDataConsent"] as? String == "denied") + #expect(recorder.sent.last?["adPersonalizationConsent"] as? String == "denied") + } + + @Test func rapidAssignments_lastAssignmentWins() async { + let (superwall, _, recorder) = makeSuperwall(attStatus: ATTStatusBox(.authorized)) + + superwall.adConsent = AdConsent(adUserData: .granted, adPersonalization: .granted) + superwall.adConsent = AdConsent(adUserData: .denied, adPersonalization: .denied) + + await waitUntil { + recorder.sent.last?["adUserDataConsent"] as? String == "denied" + } + // Give a stale snapshot the chance to land late, if it were going to. + try? await Task.sleep(nanoseconds: 300_000_000) + #expect(recorder.sent.last?["adUserDataConsent"] as? String == "denied") + #expect(recorder.sent.last?["adPersonalizationConsent"] as? String == "denied") + #expect( + recorder.sentConfigAttributes.last?["adUserDataConsent"] as? String == "denied" + ) + } + // MARK: - Republishing on ATT changes @Test func attChange_republishesDeviceAttributesOnce() async { @@ -245,7 +281,7 @@ struct AdConsentTests { /// records every device-attributes event it sends. private func makeSuperwall( attStatus: ATTStatusBox - ) -> (Superwall, DependencyContainer, DeviceAttributesRecorder) { + ) -> (Superwall, DependencyContainer, MockSuperwallDelegate) { let dependencyContainer = DependencyContainer(cache: CacheMock()) dependencyContainer.deviceHelper = DeviceHelper( api: dependencyContainer.api, @@ -257,7 +293,7 @@ struct AdConsentTests { attStatusProvider: { attStatus.value.rawValue } ) let superwall = Superwall(dependencyContainer: dependencyContainer) - let recorder = DeviceAttributesRecorder() + let recorder = MockSuperwallDelegate() dependencyContainer.delegateAdapter.swiftDelegate = recorder return (superwall, dependencyContainer, recorder) } @@ -287,17 +323,24 @@ private final class ATTStatusBox: @unchecked Sendable { } } -private final class DeviceAttributesRecorder: SuperwallDelegate, @unchecked Sendable { - private let lock = NSLock() - private var _sent: [[String: Any]] = [] - +extension MockSuperwallDelegate { + /// The attributes of every `device_attributes` event, in the order they were tracked. var sent: [[String: Any]] { - lock.withLock { _sent } + eventsReceived.compactMap { event in + if case let .deviceAttributes(attributes) = event { + return attributes + } + return nil + } } - func handleSuperwallEvent(withInfo eventInfo: SuperwallEventInfo) { - if case let .deviceAttributes(attributes) = eventInfo.event { - lock.withLock { _sent.append(attributes) } + /// The parameters of every `config_attributes` event, in the order they were tracked. + var sentConfigAttributes: [[String: Any]] { + eventInfosReceived.compactMap { info in + if case .configAttributes = info.event { + return info.params + } + return nil } } } diff --git a/Tests/SuperwallKitTests/Web/MockSuperwallDelegate.swift b/Tests/SuperwallKitTests/Web/MockSuperwallDelegate.swift index a2d3984a7d..90d88a23e4 100644 --- a/Tests/SuperwallKitTests/Web/MockSuperwallDelegate.swift +++ b/Tests/SuperwallKitTests/Web/MockSuperwallDelegate.swift @@ -12,6 +12,7 @@ import Foundation final class MockSuperwallDelegate: SuperwallDelegate { var receivedResult: RedemptionResult? var eventsReceived: [SuperwallEvent] = [] + var eventInfosReceived: [SuperwallEventInfo] = [] var receivedUserAttributes: [String: Any]? var willRedeemCallCount = 0 var willRedeemCalledAt: Date? @@ -37,6 +38,7 @@ final class MockSuperwallDelegate: SuperwallDelegate { func handleSuperwallEvent(withInfo eventInfo: SuperwallEventInfo) { eventsReceived.append(eventInfo.event) + eventInfosReceived.append(eventInfo) } func userAttributesDidChange(newAttributes: [String: Any]) { From a55a7ac16c814b45641d321334d2dbf42fbdd609 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:40:03 +0200 Subject: [PATCH 06/15] Test the paywall tracking-permission republish hook `PaywallMessageHandler` takes the republish as an injectable closure (defaulting to `Superwall.shared`) so the test can drive it. A tracking request that changes ATT from undetermined to denied after an initial upload sends exactly one device_attributes update with personalization denied; a non-tracking request sends none. Co-Authored-By: Claude Opus 5.5 --- .../PaywallMessageHandler.swift | 10 +++- .../Config/AdConsentTests.swift | 53 +++++++++++++++++++ .../PaywallMessageHandlerDelegateMock.swift | 3 ++ 3 files changed, 64 insertions(+), 2 deletions(-) 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 33e20a0b16..ca4ef92c11 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 @@ -39,6 +39,8 @@ final class PaywallMessageHandler: WebEventDelegate { private let factory: VariablesFactory private let permissionHandler: PermissionHandling private let customCallbackRegistry: CustomCallbackRegistry + /// Re-sends device attributes if the ATT answer changed the ad consent they report. + private let republishIfAdConsentChanged: () async -> Void struct EnqueuedMessage { let name: String @@ -51,8 +53,12 @@ final class PaywallMessageHandler: WebEventDelegate { receiptManager: ReceiptManager, factory: VariablesFactory, permissionHandler: PermissionHandling, - customCallbackRegistry: CustomCallbackRegistry + customCallbackRegistry: CustomCallbackRegistry, + republishIfAdConsentChanged: @escaping () async -> Void = { + await Superwall.shared.republishDeviceAttributesIfAdConsentChanged() + } ) { + self.republishIfAdConsentChanged = republishIfAdConsentChanged self.receiptManager = receiptManager self.factory = factory self.permissionHandler = permissionHandler @@ -678,7 +684,7 @@ final class PaywallMessageHandler: WebEventDelegate { let status = await permissionHandler.requestPermission(permissionType) if permissionType == .tracking { - await Superwall.shared.republishDeviceAttributesIfAdConsentChanged() + await republishIfAdConsentChanged() } // Track permission result event diff --git a/Tests/SuperwallKitTests/Config/AdConsentTests.swift b/Tests/SuperwallKitTests/Config/AdConsentTests.swift index 31388ddb4d..78f341ec81 100644 --- a/Tests/SuperwallKitTests/Config/AdConsentTests.swift +++ b/Tests/SuperwallKitTests/Config/AdConsentTests.swift @@ -249,6 +249,59 @@ struct AdConsentTests { withExtendedLifetime((appSessionManager, sessionDelegate, dependencyContainer)) {} } + @Test(arguments: [ + (PermissionType.tracking, "denied", 2), + (PermissionType.notification, "granted", 1) + ]) + @MainActor + func paywallPermissionRequest_republishesOnlyForTracking( + permissionType: PermissionType, + expectedPersonalization: String, + expectedSends: Int + ) async { + let attStatus = ATTStatusBox(.notDetermined) + let (superwall, dependencyContainer, recorder) = makeSuperwall(attStatus: attStatus) + let initial = await dependencyContainer.makeSessionDeviceAttributes() + await superwall.track(InternalSuperwallEvent.DeviceAttributes(deviceAttributes: initial)) + #expect(recorder.sent.count == 1) + + // The user denies tracking while the request is in flight. For a + // non-tracking request, the hook mustn't run even though ATT changed. + let permissions = FakePermissionHandler() + permissions.permissionToReturn = .denied + permissions.onRequest = { _ in attStatus.value = .denied } + let messageHandler = PaywallMessageHandler( + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + permissionHandler: permissions, + customCallbackRegistry: dependencyContainer.customCallbackRegistry, + republishIfAdConsentChanged: { + await superwall.republishDeviceAttributesIfAdConsentChanged() + } + ) + let webView = FakeWebView( + isMac: false, + messageHandler: messageHandler, + isOnDeviceCacheEnabled: true, + factory: dependencyContainer + ) + let delegate = PaywallMessageHandlerDelegateMock(paywallInfo: .stub(), webView: webView) + messageHandler.delegate = delegate + + messageHandler.handle( + .requestPermission(permissionType: permissionType, requestId: "request") + ) + + // `permission_result` goes back to the web view after the republish hook. + await waitUntil { webView.willHandleJs } + #expect(webView.willHandleJs) + #expect(recorder.sent.count == expectedSends) + #expect( + recorder.sent.last?["adPersonalizationConsent"] as? String == expectedPersonalization + ) + withExtendedLifetime(delegate) {} + } + @Test func options_serializeAdConsentForConfigAttributes() { let options = SuperwallOptions() options.adConsent = AdConsent(adUserData: .denied, adPersonalization: .granted) diff --git a/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerDelegateMock.swift b/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerDelegateMock.swift index 1d2ae107e8..d05d237f89 100644 --- a/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerDelegateMock.swift +++ b/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerDelegateMock.swift @@ -11,6 +11,8 @@ import Foundation final class FakePermissionHandler: PermissionHandling { var permissionToReturn: PermissionStatus = .granted var requestedPermissions: [PermissionType] = [] + /// Runs while the request is in flight, e.g. to change what the user answered. + var onRequest: ((PermissionType) -> Void)? func hasPermission(_ permission: PermissionType) async -> PermissionStatus { return permissionToReturn @@ -18,6 +20,7 @@ final class FakePermissionHandler: PermissionHandling { func requestPermission(_ permission: PermissionType) async -> PermissionStatus { requestedPermissions.append(permission) + onRequest?(permission) return permissionToReturn } } From 8ed70bf6a1f5d75f9c20793810f773f87ac35a85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:45:41 +0200 Subject: [PATCH 07/15] Only count device attributes as sent once they're queued The ad personalization value last sent was recorded even when the placements queue dropped the event under `.none`, so a consent change made while opted out was never re-sent after opting back in. It's now recorded only when the queue accepts the event and tracking isn't `.none`, the republish check no longer marks anything sent and does nothing under `.none`, and switching away from `.none` runs the serialized republish-if-changed once the queue accepts events again. Also moves `@discardableResult` below the doc comment on `republishDeviceAttributesIfAdConsentChanged()`. Co-Authored-By: Claude Opus 5.5 --- .../Internal Tracking/Tracking.swift | 9 ++++-- .../Network/Device Helper/DeviceHelper.swift | 21 ++++++------ .../Storage/PlacementsQueue.swift | 7 ++-- Sources/SuperwallKit/Superwall.swift | 10 ++++-- .../Config/AdConsentTests.swift | 32 +++++++++++++++++++ 5 files changed, 63 insertions(+), 16 deletions(-) diff --git a/Sources/SuperwallKit/Analytics/Internal Tracking/Tracking.swift b/Sources/SuperwallKit/Analytics/Internal Tracking/Tracking.swift index f2cd2347a0..748ed3d837 100644 --- a/Sources/SuperwallKit/Analytics/Internal Tracking/Tracking.swift +++ b/Sources/SuperwallKit/Analytics/Internal Tracking/Tracking.swift @@ -52,19 +52,24 @@ extension Superwall { let verbosePlacements = existingDisableVerboseEvents ?? previousDisableVerboseEvents + var isQueuedForSending = false if TrackingLogic.isNotDisabledVerbosePlacement( event, disableVerbosePlacements: verbosePlacements, isSandbox: dependencyContainer.makeIsSandbox() ) { - await dependencyContainer.placementsQueue.enqueue( + isQueuedForSending = await dependencyContainer.placementsQueue.enqueue( data: eventData.jsonData, from: event ) } dependencyContainer.storage.coreDataManager.savePlacementData(eventData) - if let deviceAttributes = event as? InternalSuperwallEvent.DeviceAttributes { + // Only what actually goes to the server counts as sent. The queue learns of a + // tracking-behavior change asynchronously, so check the options too. + if isQueuedForSending, + dependencyContainer.configManager.options.eventTrackingBehavior != .none, + let deviceAttributes = event as? InternalSuperwallEvent.DeviceAttributes { dependencyContainer.deviceHelper.recordPublishedDeviceAttributes( deviceAttributes.deviceAttributes ) diff --git a/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift index e99c130973..30399c9ed2 100644 --- a/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift +++ b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift @@ -993,22 +993,23 @@ class DeviceHelper { } /// Whether the ad personalization consent has changed since device attributes - /// were last sent, for example because the user answered the ATT prompt. When it - /// has, it's marked as sent so concurrent callers republish only once. + /// were last sent, for example because the user answered the ATT prompt. /// - /// Returns `false` before the first send, which reports the current value anyway. - func claimAdPersonalizationConsentRepublish() -> Bool { + /// Returns `false` before the first send, which reports the current value anyway, + /// and while `eventTrackingBehavior` is ``EventTrackingBehavior/none``, when nothing + /// would be sent. Callers serialize through ``AdConsentUpdateQueue`` so the send + /// is recorded before the next check. + func adPersonalizationConsentNeedsRepublish() -> Bool { + if factory.makeSuperwallOptions().eventTrackingBehavior == .none { + return false + } let current = reportedAdConsent.adPersonalization.description adConsentLock.lock() defer { adConsentLock.unlock() } - guard - let published = publishedAdPersonalizationConsent, - published != current - else { + guard let published = publishedAdPersonalizationConsent else { return false } - publishedAdPersonalizationConsent = current - return true + return published != current } deinit { diff --git a/Sources/SuperwallKit/Storage/PlacementsQueue.swift b/Sources/SuperwallKit/Storage/PlacementsQueue.swift index f0cdb8e6ee..bdd4860d56 100644 --- a/Sources/SuperwallKit/Storage/PlacementsQueue.swift +++ b/Sources/SuperwallKit/Storage/PlacementsQueue.swift @@ -73,14 +73,17 @@ actor PlacementsQueue { } } + /// Queues `data` for sending. Returns whether the tracking behavior allowed it. + @discardableResult func enqueue( data: JSON, from placement: Trackable - ) { + ) -> Bool { guard trackingAllowed(from: placement) else { - return + return false } elements.append(data) + return true } func setTrackingBehavior(_ behavior: EventTrackingBehavior) { diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index 89f8c7a478..8436b39cd3 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -76,6 +76,7 @@ public final class Superwall: NSObject, ObservableObject { return options.eventTrackingBehavior } set { + let wasOptedOut = options.eventTrackingBehavior == .none options.eventTrackingBehavior = newValue if newValue != .none { @@ -84,6 +85,11 @@ public final class Superwall: NSObject, ObservableObject { Task { await dependencyContainer.placementsQueue.setTrackingBehavior(newValue) + // Nothing was sent while opted out, so catch up on any ad consent change, + // such as an ATT answer, now that the queue accepts events again. + if wasOptedOut && newValue != .none { + await republishDeviceAttributesIfAdConsentChanged() + } } let behavior = newValue @@ -1027,10 +1033,10 @@ public final class Superwall: NSObject, ObservableObject { /// Re-sends device attributes if the ad personalization consent they report has /// changed since they were last sent, such as after the user answers the ATT /// prompt mid-session. Never prompts. Returns whether it sent them. - @discardableResult /// /// Goes through the same queue as ``adConsent`` assignments, so it can't overtake /// one. If an assignment is made while it waits, it defers to that. + @discardableResult func republishDeviceAttributesIfAdConsentChanged() async -> Bool { let generation = adConsentUpdates.currentGeneration return await withCheckedContinuation { continuation in @@ -1038,7 +1044,7 @@ public final class Superwall: NSObject, ObservableObject { guard let self, self.adConsentUpdates.isCurrent(generation), - self.dependencyContainer.deviceHelper.claimAdPersonalizationConsentRepublish() + self.dependencyContainer.deviceHelper.adPersonalizationConsentNeedsRepublish() else { continuation.resume(returning: false) return diff --git a/Tests/SuperwallKitTests/Config/AdConsentTests.swift b/Tests/SuperwallKitTests/Config/AdConsentTests.swift index 78f341ec81..8bc66253bb 100644 --- a/Tests/SuperwallKitTests/Config/AdConsentTests.swift +++ b/Tests/SuperwallKitTests/Config/AdConsentTests.swift @@ -199,6 +199,38 @@ struct AdConsentTests { #expect(recorder.sent.count == 2) } + @Test func attChangeWhileOptedOut_republishesOnceTrackingResumes() async { + let attStatus = ATTStatusBox(.notDetermined) + let (superwall, dependencyContainer, recorder) = makeSuperwall(attStatus: attStatus) + + // `.all` sends granted. + let initial = await dependencyContainer.makeSessionDeviceAttributes() + await superwall.track(InternalSuperwallEvent.DeviceAttributes(deviceAttributes: initial)) + #expect(recorder.sent.last?["adPersonalizationConsent"] as? String == "granted") + + // Opted out, the user denies tracking. Activation mustn't treat that as sent. + superwall.eventTrackingBehavior = .none + attStatus.value = .denied + #expect(await superwall.republishDeviceAttributesIfAdConsentChanged() == false) + // Even a device-attributes event tracked while opted out is dropped, not sent. + let droppedAttributes = await dependencyContainer.makeSessionDeviceAttributes() + await superwall.track( + InternalSuperwallEvent.DeviceAttributes(deviceAttributes: droppedAttributes) + ) + + // Opting back in sends the denied value exactly once. + let sentBeforeOptIn = recorder.sent.count + superwall.eventTrackingBehavior = .all + await waitUntil { recorder.sent.count > sentBeforeOptIn } + try? await Task.sleep(nanoseconds: 300_000_000) + #expect(recorder.sent.count == sentBeforeOptIn + 1) + #expect(recorder.sent.last?["adPersonalizationConsent"] as? String == "denied") + + // Now it's sent, activation has nothing more to do. + #expect(await superwall.republishDeviceAttributesIfAdConsentChanged() == false) + #expect(recorder.sent.count == sentBeforeOptIn + 1) + } + @Test func attChange_beforeAnyUpload_doesNotRepublish() async { let attStatus = ATTStatusBox(.notDetermined) let (superwall, _, recorder) = makeSuperwall(attStatus: attStatus) From 5757f0335d9d204f878a15e8cae5db971aa7884a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:15:07 +0200 Subject: [PATCH 08/15] Always re-send device attributes when leaving .none An `adConsent` assignment made while opted out that didn't change personalization (e.g. only `adUserData: .denied`) never reached device_attributes, because leaving `.none` only republished when personalization changed. It now always re-sends them through the serialized ad consent queue, matching Android. The ATT-change path is unchanged. Also fixes the CI crash: the activation test posted the app-wide didBecomeActive notification, which woke AppSessionManagers from suites running in parallel whose unowned dependencies were already gone. The activation handler is now an awaitable `didBecomeActive()` that the test calls directly. Co-Authored-By: Claude Opus 5.5 --- .../App Session/AppSessionManager.swift | 14 ++++-- Sources/SuperwallKit/Superwall.swift | 17 +++++-- .../Config/AdConsentTests.swift | 46 +++++++++++++------ 3 files changed, 55 insertions(+), 22 deletions(-) diff --git a/Sources/SuperwallKit/Analytics/App Session/AppSessionManager.swift b/Sources/SuperwallKit/Analytics/App Session/AppSessionManager.swift index 16c6f110d5..ad27e9bd22 100644 --- a/Sources/SuperwallKit/Analytics/App Session/AppSessionManager.swift +++ b/Sources/SuperwallKit/Analytics/App Session/AppSessionManager.swift @@ -101,13 +101,19 @@ class AppSessionManager { @objc private func applicationDidBecomeActive() { Task { - await Superwall.shared.track(InternalSuperwallEvent.AppOpen()) - await sessionCouldRefresh() - // After the session check, which may already have sent fresh device attributes. - await republishIfAdConsentChanged() + await didBecomeActive() } } + /// Handles the app becoming active. Internal so tests can await it rather than post + /// the app-wide notification, which every live manager would react to. + func didBecomeActive() async { + await Superwall.shared.track(InternalSuperwallEvent.AppOpen()) + await sessionCouldRefresh() + // After the session check, which may already have sent fresh device attributes. + await republishIfAdConsentChanged() + } + // MARK: - Logic /// Tries to track a new app session, then app launch, then first seen. diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index 8436b39cd3..ca6231d997 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -85,10 +85,10 @@ public final class Superwall: NSObject, ObservableObject { Task { await dependencyContainer.placementsQueue.setTrackingBehavior(newValue) - // Nothing was sent while opted out, so catch up on any ad consent change, - // such as an ATT answer, now that the queue accepts events again. + // Nothing was sent while opted out, such as an ad consent assignment or an + // ATT answer, so re-send the device attributes now the queue accepts them. if wasOptedOut && newValue != .none { - await republishDeviceAttributesIfAdConsentChanged() + await republishDeviceAttributes(onlyIfAdConsentChanged: false) } } @@ -1038,13 +1038,22 @@ public final class Superwall: NSObject, ObservableObject { /// one. If an assignment is made while it waits, it defers to that. @discardableResult func republishDeviceAttributesIfAdConsentChanged() async -> Bool { + return await republishDeviceAttributes(onlyIfAdConsentChanged: true) + } + + /// Re-sends device attributes through the same queue as ``adConsent`` assignments. + /// With `onlyIfAdConsentChanged`, it does so only when the ad personalization + /// consent differs from what was last sent. Returns whether it sent them. + @discardableResult + func republishDeviceAttributes(onlyIfAdConsentChanged: Bool) async -> Bool { let generation = adConsentUpdates.currentGeneration return await withCheckedContinuation { continuation in adConsentUpdates.enqueue { [weak self] in guard let self, self.adConsentUpdates.isCurrent(generation), - self.dependencyContainer.deviceHelper.adPersonalizationConsentNeedsRepublish() + !onlyIfAdConsentChanged + || self.dependencyContainer.deviceHelper.adPersonalizationConsentNeedsRepublish() else { continuation.resume(returning: false) return diff --git a/Tests/SuperwallKitTests/Config/AdConsentTests.swift b/Tests/SuperwallKitTests/Config/AdConsentTests.swift index 8bc66253bb..3bfd253130 100644 --- a/Tests/SuperwallKitTests/Config/AdConsentTests.swift +++ b/Tests/SuperwallKitTests/Config/AdConsentTests.swift @@ -8,7 +8,6 @@ @testable import SuperwallKit import Testing import Foundation -import UIKit struct AdConsentTests { @Test func defaults_areGranted() { @@ -231,6 +230,33 @@ struct AdConsentTests { #expect(recorder.sent.count == sentBeforeOptIn + 1) } + @Test func adConsentSetWhileOptedOut_isSentOnceTrackingResumes() async { + let (superwall, dependencyContainer, recorder) = makeSuperwall( + attStatus: ATTStatusBox(.authorized) + ) + + // Both granted are sent. + let initial = await dependencyContainer.makeSessionDeviceAttributes() + await superwall.track(InternalSuperwallEvent.DeviceAttributes(deviceAttributes: initial)) + #expect(recorder.sent.last?["adUserDataConsent"] as? String == "granted") + + // Opted out, only ad user data is denied. That doesn't change personalization, + // and what the setter tracks is dropped. + superwall.eventTrackingBehavior = .none + let sentBeforeAssignment = recorder.sent.count + superwall.adConsent = AdConsent(adUserData: .denied) + await waitUntil { recorder.sent.count > sentBeforeAssignment } + + // Opting back in always re-sends the device attributes, exactly once. + let sentBeforeOptIn = recorder.sent.count + superwall.eventTrackingBehavior = .all + await waitUntil { recorder.sent.count > sentBeforeOptIn } + try? await Task.sleep(nanoseconds: 300_000_000) + #expect(recorder.sent.count == sentBeforeOptIn + 1) + #expect(recorder.sent.last?["adUserDataConsent"] as? String == "denied") + #expect(recorder.sent.last?["adPersonalizationConsent"] as? String == "granted") + } + @Test func attChange_beforeAnyUpload_doesNotRepublish() async { let attStatus = ATTStatusBox(.notDetermined) let (superwall, _, recorder) = makeSuperwall(attStatus: attStatus) @@ -256,26 +282,18 @@ struct AdConsentTests { await superwall.republishDeviceAttributesIfAdConsentChanged() } ) - // Let the manager register its observers. - try? await Task.sleep(nanoseconds: 100_000_000) - let initial = await dependencyContainer.makeSessionDeviceAttributes() await superwall.track(InternalSuperwallEvent.DeviceAttributes(deviceAttributes: initial)) - // Activation without an ATT change sends nothing. - await NotificationCenter.default.post( - Notification(name: UIApplication.didBecomeActiveNotification) - ) - try? await Task.sleep(nanoseconds: 300_000_000) + // Activation without an ATT change sends nothing. The handler is awaited + // directly: posting the app-wide notification would also wake managers from + // suites running in parallel, whose dependencies may already be gone. + await appSessionManager.didBecomeActive() #expect(recorder.sent.count == 1) // Activation after the user denied tracking sends exactly one update. attStatus.value = .denied - await NotificationCenter.default.post( - Notification(name: UIApplication.didBecomeActiveNotification) - ) - await waitUntil { recorder.sent.count >= 2 } - try? await Task.sleep(nanoseconds: 300_000_000) + await appSessionManager.didBecomeActive() #expect(recorder.sent.count == 2) #expect(recorder.sent.last?["adPersonalizationConsent"] as? String == "denied") withExtendedLifetime((appSessionManager, sessionDelegate, dependencyContainer)) {} From 5c1f9aa7835886dfee2317bdaa6426164def131e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:21:46 +0200 Subject: [PATCH 09/15] Let tests give AppSessionManager its own NotificationCenter AppSessionManager observes lifecycle notifications on an injectable center that defaults to `.default`, so production is unchanged. The ad consent activation test passes a private one, so didBecomeActive posts from other suites can't start handlers that outlive it. Co-Authored-By: Claude Opus 5.5 --- .../Analytics/App Session/AppSessionManager.swift | 12 ++++++++---- Tests/SuperwallKitTests/Config/AdConsentTests.swift | 9 ++++++--- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/Sources/SuperwallKit/Analytics/App Session/AppSessionManager.swift b/Sources/SuperwallKit/Analytics/App Session/AppSessionManager.swift index ad27e9bd22..03ed032848 100644 --- a/Sources/SuperwallKit/Analytics/App Session/AppSessionManager.swift +++ b/Sources/SuperwallKit/Analytics/App Session/AppSessionManager.swift @@ -21,6 +21,8 @@ class AppSessionManager { private unowned let delegate: DeviceHelperFactory & UserAttributesPlacementFactory /// Catches ATT changes made while the app was in the background, e.g. in Settings. private let republishIfAdConsentChanged: () async -> Void + /// Where app lifecycle notifications are observed. Tests pass their own. + private let notificationCenter: NotificationCenter init( configManager: ConfigManager, @@ -29,9 +31,11 @@ class AppSessionManager { delegate: DeviceHelperFactory & UserAttributesPlacementFactory, republishIfAdConsentChanged: @escaping () async -> Void = { await Superwall.shared.republishDeviceAttributesIfAdConsentChanged() - } + }, + notificationCenter: NotificationCenter = .default ) { self.republishIfAdConsentChanged = republishIfAdConsentChanged + self.notificationCenter = notificationCenter self.configManager = configManager self.storage = storage self.delegate = delegate @@ -44,19 +48,19 @@ class AppSessionManager { // MARK: - Listeners @MainActor private func addActiveStateObservers() { - NotificationCenter.default.addObserver( + notificationCenter.addObserver( self, selector: #selector(applicationWillResignActive), name: UIApplication.willResignActiveNotification, object: nil ) - NotificationCenter.default.addObserver( + notificationCenter.addObserver( self, selector: #selector(applicationDidBecomeActive), name: UIApplication.didBecomeActiveNotification, object: nil ) - NotificationCenter.default.addObserver( + notificationCenter.addObserver( self, selector: #selector(applicationWillTerminate), name: UIApplication.willTerminateNotification, diff --git a/Tests/SuperwallKitTests/Config/AdConsentTests.swift b/Tests/SuperwallKitTests/Config/AdConsentTests.swift index 3bfd253130..f6a9bc4b07 100644 --- a/Tests/SuperwallKitTests/Config/AdConsentTests.swift +++ b/Tests/SuperwallKitTests/Config/AdConsentTests.swift @@ -280,14 +280,17 @@ struct AdConsentTests { delegate: sessionDelegate, republishIfAdConsentChanged: { await superwall.republishDeviceAttributesIfAdConsentChanged() - } + }, + // Not `.default`, so app-wide lifecycle notifications posted by other suites + // can't start handlers that outlive this test. + notificationCenter: NotificationCenter() ) let initial = await dependencyContainer.makeSessionDeviceAttributes() await superwall.track(InternalSuperwallEvent.DeviceAttributes(deviceAttributes: initial)) // Activation without an ATT change sends nothing. The handler is awaited - // directly: posting the app-wide notification would also wake managers from - // suites running in parallel, whose dependencies may already be gone. + // directly rather than posting the app-wide notification, which would also + // wake managers from suites running in parallel. await appSessionManager.didBecomeActive() #expect(recorder.sent.count == 1) From cd4f064f52dd51c4b177a425fa6c70b0ca15fa31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:20:55 +0200 Subject: [PATCH 10/15] Fix ad consent tests freeing the device helper under IdentityManager The CI crash was `IdentityManager._mergeUserAttributes` reading its `unowned` DeviceHelper after it was freed. The ad consent tests swapped `dependencyContainer.deviceHelper` for one with a stubbed ATT status, so the container's original helper was released while IdentityManager's queued alias/seed merge still held it unowned. - `DeviceHelper.attStatusProvider` is now settable, and the tests stub it on the container's own helper instead of replacing the helper. - Adds an internal `waitForPendingAdConsentUpdates()`, and each test drains the ad consent work it started (bounded, so a stalled StoreKit or Core Data call fails the test instead of hanging the run) and keeps its container alive until then. Co-Authored-By: Claude Opus 5.5 --- .../Network/Device Helper/DeviceHelper.swift | 4 +- Sources/SuperwallKit/Superwall.swift | 10 +++ .../Config/AdConsentTests.swift | 73 +++++++++++++------ 3 files changed, 65 insertions(+), 22 deletions(-) diff --git a/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift index 30399c9ed2..4fd038e60c 100644 --- a/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift +++ b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift @@ -16,7 +16,9 @@ import StoreKit class DeviceHelper { private let ipCollector: DeviceIPCollector /// Reads the ATT status without prompting, or `nil` where the OS has no such concept. - private let attStatusProvider: () -> Int? + /// Settable so tests can fix the status on a container's own helper; replacing + /// the helper would leave other services' `unowned` references to it dangling. + var attStatusProvider: () -> Int? /// The `adPersonalizationConsent` in the last device attributes sent, or `nil` /// before the first send. Guarded by `adConsentLock`. private var publishedAdPersonalizationConsent: String? diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index ca6231d997..d806c1fc52 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -1065,6 +1065,16 @@ public final class Superwall: NSObject, ObservableObject { } } + /// Waits for every ad consent update queued so far to finish. For tests, so the + /// work a test starts can't outlive it. + func waitForPendingAdConsentUpdates() async { + await withCheckedContinuation { continuation in + adConsentUpdates.enqueue { + continuation.resume() + } + } + } + /// Sets the user interface style, which overrides the system setting. Set to `nil` to revert /// back to using the system setting. public func setInterfaceStyle(to interfaceStyle: InterfaceStyle?) { diff --git a/Tests/SuperwallKitTests/Config/AdConsentTests.swift b/Tests/SuperwallKitTests/Config/AdConsentTests.swift index f6a9bc4b07..423faab478 100644 --- a/Tests/SuperwallKitTests/Config/AdConsentTests.swift +++ b/Tests/SuperwallKitTests/Config/AdConsentTests.swift @@ -59,6 +59,8 @@ struct AdConsentTests { let updated = await deviceHelper.getTemplateDevice() #expect(updated["adUserDataConsent"] as? String == "granted") #expect(updated["adPersonalizationConsent"] as? String == "denied") + await drainAdConsentUpdates(superwall) + withExtendedLifetime(dependencyContainer) {} } @Test func eventTrackingBehaviorNone_reportsDenied() async { @@ -75,6 +77,7 @@ struct AdConsentTests { // The developer's choice itself is left untouched. #expect(options.adConsent.adUserData == .granted) #expect(options.adConsent.adPersonalization == .granted) + withExtendedLifetime(dependencyContainer) {} } @Test(arguments: [FakeTrackingAuthorizationStatus.denied, .restricted]) @@ -91,6 +94,7 @@ struct AdConsentTests { // The stored option, which `config_attributes` reports, is left untouched. #expect(options.adConsent.adPersonalization == .granted) #expect(options.toDictionary()["adPersonalizationConsent"] as? String == "granted") + withExtendedLifetime(dependencyContainer) {} } @Test(arguments: [FakeTrackingAuthorizationStatus.notDetermined, .authorized]) @@ -110,6 +114,7 @@ struct AdConsentTests { let denied = await deviceHelper.getTemplateDevice() #expect(denied["adUserDataConsent"] as? String == "granted") #expect(denied["adPersonalizationConsent"] as? String == "denied") + withExtendedLifetime(dependencyContainer) {} } @Test func attDenied_withTrackingNone_deniesBoth() { @@ -150,6 +155,7 @@ struct AdConsentTests { #expect(recorder.sent.count == 1) #expect(recorder.sent.last?["adUserDataConsent"] as? String == "denied") #expect(recorder.sent.last?["adPersonalizationConsent"] as? String == "denied") + await drainAdConsentUpdates(superwall) } @Test func rapidAssignments_lastAssignmentWins() async { @@ -168,6 +174,7 @@ struct AdConsentTests { #expect( recorder.sentConfigAttributes.last?["adUserDataConsent"] as? String == "denied" ) + await drainAdConsentUpdates(superwall) } // MARK: - Republishing on ATT changes @@ -196,6 +203,8 @@ struct AdConsentTests { // Already reflected: no second republish. #expect(await superwall.republishDeviceAttributesIfAdConsentChanged() == false) #expect(recorder.sent.count == 2) + await drainAdConsentUpdates(superwall) + withExtendedLifetime(dependencyContainer) {} } @Test func attChangeWhileOptedOut_republishesOnceTrackingResumes() async { @@ -228,6 +237,8 @@ struct AdConsentTests { // Now it's sent, activation has nothing more to do. #expect(await superwall.republishDeviceAttributesIfAdConsentChanged() == false) #expect(recorder.sent.count == sentBeforeOptIn + 1) + await drainAdConsentUpdates(superwall) + withExtendedLifetime(dependencyContainer) {} } @Test func adConsentSetWhileOptedOut_isSentOnceTrackingResumes() async { @@ -255,6 +266,8 @@ struct AdConsentTests { #expect(recorder.sent.count == sentBeforeOptIn + 1) #expect(recorder.sent.last?["adUserDataConsent"] as? String == "denied") #expect(recorder.sent.last?["adPersonalizationConsent"] as? String == "granted") + await drainAdConsentUpdates(superwall) + withExtendedLifetime(dependencyContainer) {} } @Test func attChange_beforeAnyUpload_doesNotRepublish() async { @@ -266,6 +279,7 @@ struct AdConsentTests { // The first upload reports the current value, so there's nothing stale yet. #expect(await superwall.republishDeviceAttributesIfAdConsentChanged() == false) #expect(recorder.sent.isEmpty) + await drainAdConsentUpdates(superwall) } @Test func appActivation_republishesOnlyWhenATTChanged() async { @@ -300,6 +314,7 @@ struct AdConsentTests { #expect(recorder.sent.count == 2) #expect(recorder.sent.last?["adPersonalizationConsent"] as? String == "denied") withExtendedLifetime((appSessionManager, sessionDelegate, dependencyContainer)) {} + await drainAdConsentUpdates(superwall) } @Test(arguments: [ @@ -353,6 +368,8 @@ struct AdConsentTests { recorder.sent.last?["adPersonalizationConsent"] as? String == expectedPersonalization ) withExtendedLifetime(delegate) {} + await drainAdConsentUpdates(superwall) + withExtendedLifetime(dependencyContainer) {} } @Test func options_serializeAdConsentForConfigAttributes() { @@ -365,22 +382,18 @@ struct AdConsentTests { #expect(dictionary["adPersonalizationConsent"] as? String == "granted") } - /// A helper whose ATT status is fixed, since the simulator's real status - /// can't be set from a test. The container is held by the caller because the - /// helper only keeps its factory `unowned`. + /// The container's own device helper with its ATT status fixed, since the + /// simulator's real status can't be set from a test. The helper is modified + /// rather than replaced: other services hold it `unowned`, so a replaced one + /// would be freed under them. The caller keeps the container alive, since the + /// helper only holds it `unowned`. private func makeDeviceHelper( _ dependencyContainer: DependencyContainer, attStatus: FakeTrackingAuthorizationStatus ) -> DeviceHelper { - return DeviceHelper( - api: dependencyContainer.api, - storage: dependencyContainer.storage, - network: dependencyContainer.network, - entitlementsInfo: dependencyContainer.entitlementsInfo, - receiptManager: dependencyContainer.receiptManager, - factory: dependencyContainer, - attStatusProvider: { attStatus.rawValue } - ) + let deviceHelper: DeviceHelper = dependencyContainer.deviceHelper + deviceHelper.attStatusProvider = { attStatus.rawValue } + return deviceHelper } /// A `Superwall` whose device helper reads `attStatus`, with a delegate that @@ -389,21 +402,26 @@ struct AdConsentTests { attStatus: ATTStatusBox ) -> (Superwall, DependencyContainer, MockSuperwallDelegate) { let dependencyContainer = DependencyContainer(cache: CacheMock()) - dependencyContainer.deviceHelper = DeviceHelper( - api: dependencyContainer.api, - storage: dependencyContainer.storage, - network: dependencyContainer.network, - entitlementsInfo: dependencyContainer.entitlementsInfo, - receiptManager: dependencyContainer.receiptManager, - factory: dependencyContainer, - attStatusProvider: { attStatus.value.rawValue } - ) + dependencyContainer.deviceHelper.attStatusProvider = { attStatus.value.rawValue } let superwall = Superwall(dependencyContainer: dependencyContainer) let recorder = MockSuperwallDelegate() dependencyContainer.delegateAdapter.swiftDelegate = recorder return (superwall, dependencyContainer, recorder) } + /// Waits for the ad consent work the test started, so it finishes inside the + /// test. Bounded, so a StoreKit or Core Data call that stalls on a loaded + /// simulator fails the test's own expectations instead of hanging the run. + private func drainAdConsentUpdates(_ superwall: Superwall) async { + let drained = DrainedFlag() + Task { + await superwall.waitForPendingAdConsentUpdates() + drained.set() + } + await waitUntil(timeout: 5) { drained.isSet } + #expect(drained.isSet, "Ad consent updates didn't finish") + } + private func waitUntil( timeout: TimeInterval = 2, _ condition: @escaping () -> Bool @@ -415,6 +433,19 @@ struct AdConsentTests { } } +private final class DrainedFlag: @unchecked Sendable { + private let lock = NSLock() + private var _isSet = false + + var isSet: Bool { + lock.withLock { _isSet } + } + + func set() { + lock.withLock { _isSet = true } + } +} + private final class ATTStatusBox: @unchecked Sendable { private let lock = NSLock() private var _value: FakeTrackingAuthorizationStatus From 91aad7491c410e5cad461f90f7fd68d73b89856d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:20:11 +0200 Subject: [PATCH 11/15] Re-send device attributes after any tracking-behavior change `PlacementsQueue.setTrackingBehavior` discards every queued event for any behavior other than `.all`, so a device_attributes update already recorded as sent (e.g. an ATT denial) was lost when switching `.all` to `.superwallOnly` before a flush. Any change to a behavior other than `.none` (and re-setting `.superwallOnly`) now re-sends device attributes through the serialized ad consent queue once the queue has the new behavior, generalizing the leaving-`.none` path. Co-Authored-By: Claude Opus 5.5 --- .../Config/Options/AdConsent.swift | 8 +++--- .../Config/Options/SuperwallOptions.swift | 3 ++- .../Storage/PlacementsQueue.swift | 5 ++++ Sources/SuperwallKit/Superwall.swift | 14 +++++++---- .../Config/AdConsentTests.swift | 25 +++++++++++++++++++ 5 files changed, 45 insertions(+), 10 deletions(-) diff --git a/Sources/SuperwallKit/Config/Options/AdConsent.swift b/Sources/SuperwallKit/Config/Options/AdConsent.swift index ccfaae4a85..3c855459b2 100644 --- a/Sources/SuperwallKit/Config/Options/AdConsent.swift +++ b/Sources/SuperwallKit/Config/Options/AdConsent.swift @@ -26,17 +26,17 @@ public enum AdConsentStatus: Int, CustomStringConvertible, Sendable { /// A user's consent for how their data is used for advertising. /// -/// Superwall passes this on to ad networks, such as Google Ads, when it reports -/// conversions for your app. Both values default to ``AdConsentStatus/granted``. +/// Superwall passes this on with the conversions it uploads to Google Ads. Other ad +/// networks don't use it yet. Both values default to ``AdConsentStatus/granted``. /// /// It's immutable: to change consent, assign a new `AdConsent`. @objc(SWKAdConsent) @objcMembers public final class AdConsent: NSObject { - /// Consent to send the user's data to ad networks for advertising. + /// Consent to send the user's data to Google for advertising. public let adUserData: AdConsentStatus - /// Consent to use the user's data for personalized advertising. + /// Consent for Google to use the user's data for personalized advertising. public let adPersonalization: AdConsentStatus public init( diff --git a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift index e6ff4f2599..d429e93ece 100644 --- a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift +++ b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift @@ -365,7 +365,8 @@ public final class SuperwallOptions: NSObject, Encodable { public var eventTrackingBehavior: EventTrackingBehavior = .all /// The user's consent for how their data is used for advertising, which Superwall - /// passes on to ad networks such as Google Ads when it reports conversions. + /// passes on with the conversions it uploads to Google Ads. Other ad networks + /// don't use it yet. /// /// Both values default to ``AdConsentStatus/granted``. If your app has users in the /// EEA, the UK or Switzerland, set this from your consent flow. Both are reported as diff --git a/Sources/SuperwallKit/Storage/PlacementsQueue.swift b/Sources/SuperwallKit/Storage/PlacementsQueue.swift index bdd4860d56..03a284e00d 100644 --- a/Sources/SuperwallKit/Storage/PlacementsQueue.swift +++ b/Sources/SuperwallKit/Storage/PlacementsQueue.swift @@ -86,6 +86,11 @@ actor PlacementsQueue { return true } + /// The names of the events waiting to be flushed. For tests. + var queuedEventNames: [String] { + elements.compactMap { $0["event_name"].string } + } + func setTrackingBehavior(_ behavior: EventTrackingBehavior) { trackingBehavior = behavior if behavior != .all { diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index d806c1fc52..3ddf06d91d 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -76,7 +76,7 @@ public final class Superwall: NSObject, ObservableObject { return options.eventTrackingBehavior } set { - let wasOptedOut = options.eventTrackingBehavior == .none + let oldValue = options.eventTrackingBehavior options.eventTrackingBehavior = newValue if newValue != .none { @@ -85,9 +85,12 @@ public final class Superwall: NSObject, ObservableObject { Task { await dependencyContainer.placementsQueue.setTrackingBehavior(newValue) - // Nothing was sent while opted out, such as an ad consent assignment or an - // ATT answer, so re-send the device attributes now the queue accepts them. - if wasOptedOut && newValue != .none { + // Re-send the device attributes once the queue has the new behavior. Leaving + // `.none`, nothing was sent while opted out, such as an ad consent assignment + // or an ATT answer. Any behavior other than `.all` also discards the queued + // events (`PlacementsQueue.setTrackingBehavior`), which may include device + // attributes already recorded as sent. + if newValue != .none && (newValue != oldValue || newValue != .all) { await republishDeviceAttributes(onlyIfAdConsentChanged: false) } } @@ -113,7 +116,8 @@ public final class Superwall: NSObject, ObservableObject { } /// The user's consent for how their data is used for advertising, which Superwall - /// passes on to ad networks such as Google Ads when it reports conversions. + /// passes on with the conversions it uploads to Google Ads. Other ad networks + /// don't use it yet. /// /// Both values default to ``AdConsentStatus/granted``. If your app has users in the /// EEA, the UK or Switzerland, set this from your consent flow. On iOS, diff --git a/Tests/SuperwallKitTests/Config/AdConsentTests.swift b/Tests/SuperwallKitTests/Config/AdConsentTests.swift index 423faab478..dc1c3dfd8a 100644 --- a/Tests/SuperwallKitTests/Config/AdConsentTests.swift +++ b/Tests/SuperwallKitTests/Config/AdConsentTests.swift @@ -270,6 +270,31 @@ struct AdConsentTests { withExtendedLifetime(dependencyContainer) {} } + @Test func switchingToSuperwallOnly_resendsDiscardedDeviceAttributes() async { + let attStatus = ATTStatusBox(.notDetermined) + let (superwall, dependencyContainer, recorder) = makeSuperwall(attStatus: attStatus) + let queue = dependencyContainer.placementsQueue! + + // Granted is sent, then the ATT denial is queued and recorded as sent. + let initial = await dependencyContainer.makeSessionDeviceAttributes() + await superwall.track(InternalSuperwallEvent.DeviceAttributes(deviceAttributes: initial)) + attStatus.value = .denied + #expect(await superwall.republishDeviceAttributesIfAdConsentChanged() == true) + #expect(await queue.queuedEventNames.contains("device_attributes")) + + // Before a flush, `.superwallOnly` discards everything queued, the denial too. + let sentBeforeSwitch = recorder.sent.count + superwall.eventTrackingBehavior = .superwallOnly + await waitUntil { recorder.sent.count > sentBeforeSwitch } + await drainAdConsentUpdates(superwall) + + // A fresh copy went out after the switch, and the queue kept it. + #expect(recorder.sent.count == sentBeforeSwitch + 1) + #expect(recorder.sent.last?["adPersonalizationConsent"] as? String == "denied") + #expect(await queue.queuedEventNames.filter { $0 == "device_attributes" }.count == 1) + withExtendedLifetime(dependencyContainer) {} + } + @Test func attChange_beforeAnyUpload_doesNotRepublish() async { let attStatus = ATTStatusBox(.notDetermined) let (superwall, _, recorder) = makeSuperwall(attStatus: attStatus) From 1750d2d2a84a1bf00f2868859cb8dd986a77d54b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:20:11 +0200 Subject: [PATCH 12/15] Say ad consent is only passed on to Google Ads for now Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61671133ee..c7708ecdb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup - Adds the Customer Center, a self-service screen where users can view, restore, manage, cancel, refund and change their purchases, and contact support. Present it with `Superwall.shared.presentCustomerCenter()`, `CustomerCenterView` or `CustomerCenterViewController`, and configure it with `SuperwallOptions.customerCenter`. - Adds `CustomerCenterDelegate` and events for when the Customer Center opens, closes, and when users pick an action, answer a survey or request a refund. - Adds best-effort public IPv4 and IPv6 device attributes, with separate observation timestamps, for apps using the Superwall MMP. -- Adds `SuperwallOptions.adConsent` and `Superwall.shared.adConsent` to report a user's ad consent (`adUserData` and `adPersonalization`, each an `AdConsentStatus` of `.granted` or `.denied`), which Superwall passes on to ad networks such as Google Ads when it reports conversions. Both default to granted. If your app has users in the EEA, the UK or Switzerland, set them from your consent flow. They're reported as the `adUserDataConsent` and `adPersonalizationConsent` device attributes, and both as denied while `eventTrackingBehavior` is `.none`. Personalization is also reported as denied when the user has denied App Tracking Transparency or it's restricted. +- Adds `SuperwallOptions.adConsent` and `Superwall.shared.adConsent` to report a user's ad consent (`adUserData` and `adPersonalization`, each an `AdConsentStatus` of `.granted` or `.denied`), which Superwall passes on with the conversions it uploads to Google Ads. Other ad networks don't use it yet. Both default to granted. If your app has users in the EEA, the UK or Switzerland, set them from your consent flow. They're reported as the `adUserDataConsent` and `adPersonalizationConsent` device attributes, and both as denied while `eventTrackingBehavior` is `.none`. Personalization is also reported as denied when the user has denied App Tracking Transparency or it's restricted. ### Fixes From d34d273d4e597be77b924d3845d3798d44e650d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:59:19 +0200 Subject: [PATCH 13/15] Read ad consent from IAB TCF consent banners When the app never sets `adConsent` (at configure or at runtime), the SDK now uses the consent an IAB TCF v2 banner stores in UserDefaults when GDPR applies (`IABTCF_gdprApplies` == 1): ad user data is granted when purposes 1 and 7 are agreed, personalization when purposes 3 and 4 are. Otherwise consent defaults to granted. A new `adConsentSource` device attribute reports `developer`, `tcf` or `default`, and `config_attributes` reports whether the app set the option. The ATT rule now only applies to banner or default consent: an explicit developer setting is always trusted. `.none` still denies both. A banner change, seen through `UserDefaults.didChangeNotification`, republishes device attributes through the serialized ad consent queue when the reported values or source change; the change check now compares both values and the source. The defaults store and notification center are injectable for tests. Docs now say consent is passed on to Google Ads and Meta. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- .../Config/Options/AdConsent.swift | 11 +- .../Config/Options/SuperwallOptions.swift | 28 ++- .../Config/Options/TCFConsent.swift | 130 ++++++++++ .../Dependencies/DependencyContainer.swift | 3 + .../Network/Device Helper/DeviceHelper.swift | 58 +++-- .../Templating/Models/DeviceTemplate.swift | 1 + Sources/SuperwallKit/Superwall.swift | 20 +- SuperwallKit.xcodeproj/project.pbxproj | 4 + .../Config/AdConsentTests.swift | 238 +++++++++++++++++- 10 files changed, 454 insertions(+), 41 deletions(-) create mode 100644 Sources/SuperwallKit/Config/Options/TCFConsent.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index c7708ecdb3..04c7c426eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup - Adds the Customer Center, a self-service screen where users can view, restore, manage, cancel, refund and change their purchases, and contact support. Present it with `Superwall.shared.presentCustomerCenter()`, `CustomerCenterView` or `CustomerCenterViewController`, and configure it with `SuperwallOptions.customerCenter`. - Adds `CustomerCenterDelegate` and events for when the Customer Center opens, closes, and when users pick an action, answer a survey or request a refund. - Adds best-effort public IPv4 and IPv6 device attributes, with separate observation timestamps, for apps using the Superwall MMP. -- Adds `SuperwallOptions.adConsent` and `Superwall.shared.adConsent` to report a user's ad consent (`adUserData` and `adPersonalization`, each an `AdConsentStatus` of `.granted` or `.denied`), which Superwall passes on with the conversions it uploads to Google Ads. Other ad networks don't use it yet. Both default to granted. If your app has users in the EEA, the UK or Switzerland, set them from your consent flow. They're reported as the `adUserDataConsent` and `adPersonalizationConsent` device attributes, and both as denied while `eventTrackingBehavior` is `.none`. Personalization is also reported as denied when the user has denied App Tracking Transparency or it's restricted. +- Adds `SuperwallOptions.adConsent` and `Superwall.shared.adConsent` to report a user's ad consent (`adUserData` and `adPersonalization`, each an `AdConsentStatus` of `.granted` or `.denied`), which Superwall passes on with the conversions it uploads to Google Ads and Meta. If `adConsent` is never set, the SDK uses the consent stored by an IAB TCF consent banner when GDPR applies, and otherwise defaults to granted; apps with users in the EEA, UK or Switzerland that don't use a TCF banner should set it from their consent flow. Where the values came from is reported in the new `adConsentSource` device attribute (`developer`, `tcf` or `default`). The values are reported as the `adUserDataConsent` and `adPersonalizationConsent` device attributes, and both as denied while `eventTrackingBehavior` is `.none`. Personalization is also reported as denied when the user has denied App Tracking Transparency or it's restricted, unless you set `adConsent` yourself. ### Fixes diff --git a/Sources/SuperwallKit/Config/Options/AdConsent.swift b/Sources/SuperwallKit/Config/Options/AdConsent.swift index 3c855459b2..c206357151 100644 --- a/Sources/SuperwallKit/Config/Options/AdConsent.swift +++ b/Sources/SuperwallKit/Config/Options/AdConsent.swift @@ -26,17 +26,17 @@ public enum AdConsentStatus: Int, CustomStringConvertible, Sendable { /// A user's consent for how their data is used for advertising. /// -/// Superwall passes this on with the conversions it uploads to Google Ads. Other ad -/// networks don't use it yet. Both values default to ``AdConsentStatus/granted``. +/// Superwall passes this on with the conversions it uploads to Google Ads and Meta. +/// Both values default to ``AdConsentStatus/granted``. /// /// It's immutable: to change consent, assign a new `AdConsent`. @objc(SWKAdConsent) @objcMembers public final class AdConsent: NSObject { - /// Consent to send the user's data to Google for advertising. + /// Consent to send user data to ad networks for advertising. public let adUserData: AdConsentStatus - /// Consent for Google to use the user's data for personalized advertising. + /// Consent for them to use that data for personalized advertising. public let adPersonalization: AdConsentStatus public init( @@ -55,7 +55,8 @@ public final class AdConsent: NSObject { /// The consent to report. Both purposes are denied while `eventTrackingBehavior` /// is ``EventTrackingBehavior/none``, and personalization is denied when the user /// hasn't allowed tracking (`attStatus` is restricted or denied). An undetermined - /// or missing ATT status leaves the developer's choice in place. + /// or missing ATT status leaves the values in place. Pass `nil` for consent the app + /// set itself, which the ATT rule doesn't override. func reported( for eventTrackingBehavior: EventTrackingBehavior, attStatus: Int? diff --git a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift index d429e93ece..626663e11c 100644 --- a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift +++ b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift @@ -365,16 +365,28 @@ public final class SuperwallOptions: NSObject, Encodable { public var eventTrackingBehavior: EventTrackingBehavior = .all /// The user's consent for how their data is used for advertising, which Superwall - /// passes on with the conversions it uploads to Google Ads. Other ad networks - /// don't use it yet. + /// passes on with the conversions it uploads to Google Ads and Meta. /// - /// Both values default to ``AdConsentStatus/granted``. If your app has users in the - /// EEA, the UK or Switzerland, set this from your consent flow. Both are reported as - /// denied while ``eventTrackingBehavior`` is ``EventTrackingBehavior/none``. On iOS, - /// personalization is reported as denied when the user hasn't allowed tracking. + /// If `adConsent` is never set, the SDK uses the consent stored by an IAB TCF consent + /// banner when GDPR applies, and otherwise defaults to granted; apps with users in + /// the EEA, UK or Switzerland that don't use a TCF banner should set it from their + /// consent flow. Where the values came from is reported in the `adConsentSource` + /// device attribute (`developer`, `tcf` or `default`). + /// + /// Both are reported as denied while ``eventTrackingBehavior`` is + /// ``EventTrackingBehavior/none``. On iOS, personalization is reported as denied when + /// the user hasn't allowed tracking, unless you set `adConsent` yourself. /// /// You can also change this at runtime via ``Superwall/adConsent``. - public var adConsent = AdConsent() + public var adConsent = AdConsent() { + didSet { + isAdConsentSet = true + } + } + + /// Whether the app has assigned ``adConsent``, even to the default value. Until + /// then, a consent banner's stored answer takes its place. + var isAdConsentSet = false /// Enables the sending of non-Superwall tracked events and properties back to the Superwall servers. /// Defaults to `true`. @@ -550,6 +562,7 @@ public final class SuperwallOptions: NSObject, Encodable { case testModeBehavior case adUserDataConsent case adPersonalizationConsent + case isAdConsentSet } public override init() { @@ -587,6 +600,7 @@ public final class SuperwallOptions: NSObject, Encodable { try container.encode(testModeBehavior.description, forKey: .testModeBehavior) try container.encode(adConsent.adUserData.description, forKey: .adUserDataConsent) try container.encode(adConsent.adPersonalization.description, forKey: .adPersonalizationConsent) + try container.encode(isAdConsentSet, forKey: .isAdConsentSet) } func toDictionary() -> [String: Any] { diff --git a/Sources/SuperwallKit/Config/Options/TCFConsent.swift b/Sources/SuperwallKit/Config/Options/TCFConsent.swift new file mode 100644 index 0000000000..6f15516ac2 --- /dev/null +++ b/Sources/SuperwallKit/Config/Options/TCFConsent.swift @@ -0,0 +1,130 @@ +// +// TCFConsent.swift +// +// +// Created by Yusuf Tör on 09/10/2026. +// + +import Foundation + +/// Where the reported ad consent came from, before the `.none` and ATT rules apply. +enum AdConsentSource: String { + /// The app set ``SuperwallOptions/adConsent`` or ``Superwall/adConsent``. + case developer + /// An IAB TCF consent banner stored it in `UserDefaults`. + case tcf + /// Nothing set it, so both purposes are granted. + case `default` +} + +/// The ad consent device attributes report, as their string values. +struct ReportedAdConsent: Equatable { + let adUserData: String + let adPersonalization: String + let source: String + + init(adUserData: String, adPersonalization: String, source: String) { + self.adUserData = adUserData + self.adPersonalization = adPersonalization + self.source = source + } + + /// Reads them back from device attributes. + init?(attributes: [String: Any]) { + guard + let adUserData = attributes["adUserDataConsent"] as? String, + let adPersonalization = attributes["adPersonalizationConsent"] as? String, + let source = attributes["adConsentSource"] as? String + else { + return nil + } + self.init(adUserData: adUserData, adPersonalization: adPersonalization, source: source) + } +} + +/// Reads the ad consent an IAB TCF v2 consent banner stores in `UserDefaults`. +enum TCFConsent { + static let gdprAppliesKey = "IABTCF_gdprApplies" + static let purposeConsentsKey = "IABTCF_PurposeConsents" + + /// The banner's consent, or `nil` when EU rules don't apply or there's no answer. + /// + /// `adUserData` needs purposes 1 and 7, and `adPersonalization` needs purposes 3 + /// and 4. Only purpose consents are read, not vendor consents. + static func consent(from defaults: UserDefaults) -> AdConsent? { + guard + (defaults.object(forKey: gdprAppliesKey) as? Int) == 1, + let purposes = defaults.string(forKey: purposeConsentsKey), + !purposes.isEmpty + else { + return nil + } + let agreed = Array(purposes) + func hasConsent(toPurposes numbers: Int...) -> Bool { + numbers.allSatisfy { $0 <= agreed.count && agreed[$0 - 1] == "1" } + } + return AdConsent( + adUserData: hasConsent(toPurposes: 1, 7) ? .granted : .denied, + adPersonalization: hasConsent(toPurposes: 3, 4) ? .granted : .denied + ) + } +} + +/// Re-sends device attributes when a consent banner changes its stored answer, +/// for example after the user reopens the banner from the app's settings. +final class TCFConsentObserver { + private let defaults: UserDefaults + private let notificationCenter: NotificationCenter + private let onChange: () async -> Void + private let lock = NSLock() + /// The banner's answer last seen, as `adUserData`/`adPersonalization`, or `nil`. + private var lastSeen: [AdConsentStatus]? + private var observer: NSObjectProtocol? + + init( + defaults: UserDefaults = .standard, + notificationCenter: NotificationCenter = .default, + onChange: @escaping () async -> Void = { + await Superwall.shared.republishDeviceAttributesIfAdConsentChanged() + } + ) { + self.defaults = defaults + self.notificationCenter = notificationCenter + self.onChange = onChange + self.lastSeen = Self.snapshot(of: defaults) + // `didChangeNotification` fires for every write to any defaults in the + // process, so only a change to the banner's answer goes any further. + observer = notificationCenter.addObserver( + forName: UserDefaults.didChangeNotification, + object: nil, + queue: nil + ) { [weak self] _ in + self?.bannerMayHaveChanged() + } + } + + deinit { + if let observer { + notificationCenter.removeObserver(observer) + } + } + + private static func snapshot(of defaults: UserDefaults) -> [AdConsentStatus]? { + TCFConsent.consent(from: defaults).map { [$0.adUserData, $0.adPersonalization] } + } + + private func bannerMayHaveChanged() { + let current = Self.snapshot(of: defaults) + lock.lock() + let didChange = current != lastSeen + lastSeen = current + lock.unlock() + guard didChange else { + return + } + let onChange = onChange + Task { + await onChange() + } + } +} diff --git a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift index 5bed67a1cf..b954b47e1f 100644 --- a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift +++ b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift @@ -22,6 +22,7 @@ final class DependencyContainer { var identityManager: IdentityManager! var storeKitManager: StoreKitManager! var appSessionManager: AppSessionManager! + var tcfConsentObserver: TCFConsentObserver! var storage: Storage! var network: Network! var paywallManager: PaywallManager! @@ -204,6 +205,8 @@ final class DependencyContainer { delegate: self ) + tcfConsentObserver = TCFConsentObserver() + debugManager = DebugManager( storage: storage, factory: self diff --git a/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift index 4fd038e60c..3da473a943 100644 --- a/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift +++ b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift @@ -19,9 +19,11 @@ class DeviceHelper { /// Settable so tests can fix the status on a container's own helper; replacing /// the helper would leave other services' `unowned` references to it dangling. var attStatusProvider: () -> Int? - /// The `adPersonalizationConsent` in the last device attributes sent, or `nil` - /// before the first send. Guarded by `adConsentLock`. - private var publishedAdPersonalizationConsent: String? + /// Where a consent banner stores its answer. Settable so tests can use their own. + var consentDefaults: UserDefaults = .standard + /// The ad consent attributes in the last device attributes sent, or `nil` before + /// the first send. Guarded by `adConsentLock`. + private var publishedAdConsent: ReportedAdConsent? private let adConsentLock = NSLock() var localeIdentifier: String { let localeIdentifier = factory.makeLocaleIdentifier() @@ -976,39 +978,62 @@ class DeviceHelper { } /// The ad consent device attributes would report right now. - var reportedAdConsent: AdConsent { + /// + /// The app's own ``SuperwallOptions/adConsent`` wins once it's been set, then a + /// consent banner's stored answer, then the granted default. The `.none` rule then + /// applies to all of them, and the ATT rule to all but the app's own setting, + /// which is always trusted. + var reportedAdConsent: ReportedAdConsent { let options = factory.makeSuperwallOptions() - return options.adConsent.reported( + let consent: AdConsent + let source: AdConsentSource + if options.isAdConsentSet { + consent = options.adConsent + source = .developer + } else if let bannerConsent = TCFConsent.consent(from: consentDefaults) { + consent = bannerConsent + source = .tcf + } else { + consent = AdConsent() + source = .default + } + let reported = consent.reported( for: options.eventTrackingBehavior, - attStatus: attStatusProvider() + attStatus: source == .developer ? nil : attStatusProvider() + ) + return ReportedAdConsent( + adUserData: reported.adUserData.description, + adPersonalization: reported.adPersonalization.description, + source: source.rawValue ) } - /// Notes the ad personalization consent in device attributes that were just sent. + /// Notes the ad consent in device attributes that were just sent. func recordPublishedDeviceAttributes(_ attributes: [String: Any]) { - guard let consent = attributes["adPersonalizationConsent"] as? String else { + guard let consent = ReportedAdConsent(attributes: attributes) else { return } adConsentLock.lock() - publishedAdPersonalizationConsent = consent + publishedAdConsent = consent adConsentLock.unlock() } - /// Whether the ad personalization consent has changed since device attributes - /// were last sent, for example because the user answered the ATT prompt. + /// Whether the ad consent or its source has changed since device attributes were + /// last sent, for example because the user answered the ATT prompt or a consent + /// banner. /// /// Returns `false` before the first send, which reports the current value anyway, /// and while `eventTrackingBehavior` is ``EventTrackingBehavior/none``, when nothing /// would be sent. Callers serialize through ``AdConsentUpdateQueue`` so the send /// is recorded before the next check. - func adPersonalizationConsentNeedsRepublish() -> Bool { + func adConsentNeedsRepublish() -> Bool { if factory.makeSuperwallOptions().eventTrackingBehavior == .none { return false } - let current = reportedAdConsent.adPersonalization.description + let current = reportedAdConsent adConsentLock.lock() defer { adConsentLock.unlock() } - guard let published = publishedAdPersonalizationConsent else { + guard let published = publishedAdConsent else { return false } return published != current @@ -1137,8 +1162,9 @@ class DeviceHelper { compilerVersion: currentCompilerVersion(), localResourceIds: Superwall.shared.options.localResources.keys.sorted().joined(separator: ","), deviceId: factory.makeDeviceId(), - adUserDataConsent: adConsent.adUserData.description, - adPersonalizationConsent: adConsent.adPersonalization.description + adUserDataConsent: adConsent.adUserData, + adPersonalizationConsent: adConsent.adPersonalization, + adConsentSource: adConsent.source ) var deviceDictionary = template.toDictionary( diff --git a/Sources/SuperwallKit/Paywall/View Controller/Web View/Templating/Models/DeviceTemplate.swift b/Sources/SuperwallKit/Paywall/View Controller/Web View/Templating/Models/DeviceTemplate.swift index aca56d6a87..986dc36da3 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/Web View/Templating/Models/DeviceTemplate.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/Web View/Templating/Models/DeviceTemplate.swift @@ -71,6 +71,7 @@ struct DeviceTemplate: Codable { var deviceId: String var adUserDataConsent: String var adPersonalizationConsent: String + var adConsentSource: String func toDictionary(encoder: JSONEncoder = JSONEncoder()) -> [String: Any] { guard let data = try? encoder.encode(self) else { diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index 3ddf06d91d..cdafddcb0e 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -116,12 +116,16 @@ public final class Superwall: NSObject, ObservableObject { } /// The user's consent for how their data is used for advertising, which Superwall - /// passes on with the conversions it uploads to Google Ads. Other ad networks - /// don't use it yet. + /// passes on with the conversions it uploads to Google Ads and Meta. /// - /// Both values default to ``AdConsentStatus/granted``. If your app has users in the - /// EEA, the UK or Switzerland, set this from your consent flow. On iOS, - /// personalization is reported as denied when the user hasn't allowed tracking. + /// If `adConsent` is never set, the SDK uses the consent stored by an IAB TCF consent + /// banner when GDPR applies, and otherwise defaults to granted; apps with users in + /// the EEA, UK or Switzerland that don't use a TCF banner should set it from their + /// consent flow. Where the values came from is reported in the `adConsentSource` + /// device attribute (`developer`, `tcf` or `default`). + /// + /// On iOS, personalization is reported as denied when the user hasn't allowed + /// tracking, unless you set `adConsent` yourself. /// /// You can also set the initial value via ``SuperwallOptions/adConsent`` /// before calling `configure`. @@ -1046,8 +1050,8 @@ public final class Superwall: NSObject, ObservableObject { } /// Re-sends device attributes through the same queue as ``adConsent`` assignments. - /// With `onlyIfAdConsentChanged`, it does so only when the ad personalization - /// consent differs from what was last sent. Returns whether it sent them. + /// With `onlyIfAdConsentChanged`, it does so only when the reported ad consent or + /// its source differs from what was last sent. Returns whether it sent them. @discardableResult func republishDeviceAttributes(onlyIfAdConsentChanged: Bool) async -> Bool { let generation = adConsentUpdates.currentGeneration @@ -1057,7 +1061,7 @@ public final class Superwall: NSObject, ObservableObject { let self, self.adConsentUpdates.isCurrent(generation), !onlyIfAdConsentChanged - || self.dependencyContainer.deviceHelper.adPersonalizationConsentNeedsRepublish() + || self.dependencyContainer.deviceHelper.adConsentNeedsRepublish() else { continuation.resume(returning: false) return diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index 96bd2d24dd..a3022c43fb 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -677,6 +677,7 @@ FA677CF601A228D5B485FFDE /* PopupTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D5F8BE7E93645C0FCA49E4A /* PopupTransition.swift */; }; FA907E1BC8B68F238C791867 /* SuperwallDelegateAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E441343EAC43B2ECF35F929 /* SuperwallDelegateAdapter.swift */; }; FACCB02103E21B86A98E12BE /* CustomerCenterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74230D92533298E4C0DAE83A /* CustomerCenterView.swift */; }; + FADE14FA0130CB8B549CC061 /* TCFConsent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4440A6832E1195B9C1C8633A /* TCFConsent.swift */; }; FAE2C990CFBD7485E4DBA8F5 /* PresentationRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C825F0AD231C62462873E51A /* PresentationRequest.swift */; }; FAF3AF67B749AE55CACAE5F9 /* ReceiptManagerPurchasedProductFetchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4078AB25B84478F020D1B055 /* ReceiptManagerPurchasedProductFetchTests.swift */; }; FC051A3A8D640AF49D798B25 /* RawExperiment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7611B89AB647CB1AB79CA912 /* RawExperiment.swift */; }; @@ -886,6 +887,7 @@ 42956918D4FFA5FBA79F3AA5 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; 43C6C8966D32B889BEA06F70 /* AttributionDeviceIdentifiersTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributionDeviceIdentifiersTests.swift; sourceTree = ""; }; 440ABDF6DAE2C15579B93DF1 /* PushTransitionDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushTransitionDelegate.swift; sourceTree = ""; }; + 4440A6832E1195B9C1C8633A /* TCFConsent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TCFConsent.swift; sourceTree = ""; }; 45AFD6EE9BED296D075A9618 /* ASN1Templates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ASN1Templates.swift; sourceTree = ""; }; 45B3BC4249A9E9BA8E99EC7C /* CustomCallbackRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomCallbackRegistry.swift; sourceTree = ""; }; 45B62967CEF47D4315E4A3EF /* CustomerCenterPathResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerCenterPathResolverTests.swift; sourceTree = ""; }; @@ -3483,6 +3485,7 @@ 93D8033AAF5549E30ACDA3EA /* EventTrackingBehavior.swift */, B1A64CCBCB23CC1715DF79AC /* PaywallOptions.swift */, CFDA311A030FDFC45AEE248A /* SuperwallOptions.swift */, + 4440A6832E1195B9C1C8633A /* TCFConsent.swift */, ); path = Options; sourceTree = ""; @@ -4384,6 +4387,7 @@ 3C21624B627B249FB3B681FB /* SwiftVersion.swift in Sources */, AEB9D461AF5103FB7257AD25 /* SwiftyJSON.swift in Sources */, 98193046E2347D4D6D74F408 /* SystemInfo+NotificationName.swift in Sources */, + FADE14FA0130CB8B549CC061 /* TCFConsent.swift in Sources */, 534E94DCCD72F2F7D0EC1441 /* Task+Retrying.swift in Sources */, 0A0BD14265AA8CE75A84940E /* TaskCoalescer.swift in Sources */, 1FFADB97F7E05549750E14E9 /* TaskExecutor.swift in Sources */, diff --git a/Tests/SuperwallKitTests/Config/AdConsentTests.swift b/Tests/SuperwallKitTests/Config/AdConsentTests.swift index dc1c3dfd8a..757f65ab4b 100644 --- a/Tests/SuperwallKitTests/Config/AdConsentTests.swift +++ b/Tests/SuperwallKitTests/Config/AdConsentTests.swift @@ -81,22 +81,43 @@ struct AdConsentTests { } @Test(arguments: [FakeTrackingAuthorizationStatus.denied, .restricted]) - func attNotAllowed_deniesPersonalizationOnly(_ attStatus: FakeTrackingAuthorizationStatus) async { + func attNotAllowed_deniesDefaultPersonalizationOnly( + _ attStatus: FakeTrackingAuthorizationStatus + ) async { let dependencyContainer = DependencyContainer(cache: CacheMock()) let deviceHelper = makeDeviceHelper(dependencyContainer, attStatus: attStatus) let options = dependencyContainer.configManager.options - options.adConsent = AdConsent(adUserData: .granted, adPersonalization: .granted) let template = await deviceHelper.getTemplateDevice() #expect(template["adUserDataConsent"] as? String == "granted") #expect(template["adPersonalizationConsent"] as? String == "denied") + #expect(template["adConsentSource"] as? String == "default") // The stored option, which `config_attributes` reports, is left untouched. #expect(options.adConsent.adPersonalization == .granted) #expect(options.toDictionary()["adPersonalizationConsent"] as? String == "granted") withExtendedLifetime(dependencyContainer) {} } + @Test(arguments: [FakeTrackingAuthorizationStatus.denied, .restricted]) + func attNotAllowed_leavesTheAppsOwnConsentAlone( + _ attStatus: FakeTrackingAuthorizationStatus + ) async { + let dependencyContainer = DependencyContainer(cache: CacheMock()) + let deviceHelper = makeDeviceHelper(dependencyContainer, attStatus: attStatus) + dependencyContainer.configManager.options.adConsent = AdConsent( + adUserData: .granted, + adPersonalization: .granted + ) + + let template = await deviceHelper.getTemplateDevice() + + #expect(template["adUserDataConsent"] as? String == "granted") + #expect(template["adPersonalizationConsent"] as? String == "granted") + #expect(template["adConsentSource"] as? String == "developer") + withExtendedLifetime(dependencyContainer) {} + } + @Test(arguments: [FakeTrackingAuthorizationStatus.notDetermined, .authorized]) func attUndeterminedOrAuthorized_reportsTheChosenConsent( _ attStatus: FakeTrackingAuthorizationStatus @@ -139,6 +160,178 @@ struct AdConsentTests { #expect(reported.adPersonalization == .granted) } + // MARK: - Consent banners (IAB TCF) + + @Test(arguments: [ + ("1111111111", AdConsentStatus.granted, AdConsentStatus.granted), + ("1101111111", .granted, .denied), + ("0111111111", .denied, .granted), + ("0000000000", .denied, .denied) + ]) + func tcf_mapsPurposesToConsent( + purposes: String, + adUserData: AdConsentStatus, + adPersonalization: AdConsentStatus + ) { + let banner = BannerDefaults(gdprApplies: 1, purposes: purposes) + + let consent = TCFConsent.consent(from: banner.defaults) + + #expect(consent?.adUserData == adUserData) + #expect(consent?.adPersonalization == adPersonalization) + } + + @Test func tcf_shortStringCountsMissingPurposesAsNotAgreed() { + // Purposes 1 to 4 only: 7 is missing, so ad user data is denied. + let banner = BannerDefaults(gdprApplies: 1, purposes: "1111") + + let consent = TCFConsent.consent(from: banner.defaults) + + #expect(consent?.adUserData == .denied) + #expect(consent?.adPersonalization == .granted) + } + + @Test(arguments: [ + BannerFixture(gdprApplies: 0, purposes: "1111111111"), + BannerFixture(gdprApplies: nil, purposes: "1111111111"), + BannerFixture(gdprApplies: 1, purposes: ""), + BannerFixture(gdprApplies: 1, purposes: nil) + ]) + func tcf_withoutEURulesOrAnAnswer_fallsBackToDefault(_ fixture: BannerFixture) async { + let banner = BannerDefaults(gdprApplies: fixture.gdprApplies, purposes: fixture.purposes) + #expect(TCFConsent.consent(from: banner.defaults) == nil) + + let dependencyContainer = DependencyContainer(cache: CacheMock()) + let deviceHelper = makeDeviceHelper(dependencyContainer, attStatus: .authorized, banner: banner) + let template = await deviceHelper.getTemplateDevice() + + #expect(template["adUserDataConsent"] as? String == "granted") + #expect(template["adPersonalizationConsent"] as? String == "granted") + #expect(template["adConsentSource"] as? String == "default") + withExtendedLifetime(dependencyContainer) {} + } + + @Test func tcf_isReportedWhenTheAppHasNotSetConsent() async { + let banner = BannerDefaults(gdprApplies: 1, purposes: "0000000000") + let dependencyContainer = DependencyContainer(cache: CacheMock()) + let deviceHelper = makeDeviceHelper(dependencyContainer, attStatus: .authorized, banner: banner) + + let template = await deviceHelper.getTemplateDevice() + + #expect(template["adUserDataConsent"] as? String == "denied") + #expect(template["adPersonalizationConsent"] as? String == "denied") + #expect(template["adConsentSource"] as? String == "tcf") + // The stored option, which `config_attributes` reports, isn't touched. + let options = dependencyContainer.configManager.options + #expect(options.toDictionary()["adUserDataConsent"] as? String == "granted") + #expect(options.toDictionary()["isAdConsentSet"] as? Bool == false) + withExtendedLifetime(dependencyContainer) {} + } + + @Test func developerConsent_winsOverTheBanner_evenWhenSetToTheDefault() async { + let banner = BannerDefaults(gdprApplies: 1, purposes: "0000000000") + let dependencyContainer = DependencyContainer(cache: CacheMock()) + let deviceHelper = makeDeviceHelper(dependencyContainer, attStatus: .authorized, banner: banner) + + // Set through the options, as at configure. + let options = dependencyContainer.configManager.options + options.adConsent = AdConsent() + let template = await deviceHelper.getTemplateDevice() + #expect(template["adUserDataConsent"] as? String == "granted") + #expect(template["adPersonalizationConsent"] as? String == "granted") + #expect(template["adConsentSource"] as? String == "developer") + #expect(options.toDictionary()["isAdConsentSet"] as? Bool == true) + withExtendedLifetime(dependencyContainer) {} + } + + @Test func developerConsentSetAtRuntime_winsOverTheBanner() async { + let banner = BannerDefaults(gdprApplies: 1, purposes: "0000000000") + let (superwall, dependencyContainer, _) = makeSuperwall( + attStatus: ATTStatusBox(.authorized), + banner: banner + ) + #expect(dependencyContainer.deviceHelper.reportedAdConsent.source == "tcf") + + superwall.adConsent = AdConsent() + + let reported = dependencyContainer.deviceHelper.reportedAdConsent + #expect(reported == ReportedAdConsent( + adUserData: "granted", + adPersonalization: "granted", + source: "developer" + )) + await drainAdConsentUpdates(superwall) + } + + @Test func tcf_attStillDeniesPersonalization() async { + let banner = BannerDefaults(gdprApplies: 1, purposes: "1111111111") + let dependencyContainer = DependencyContainer(cache: CacheMock()) + let deviceHelper = makeDeviceHelper(dependencyContainer, attStatus: .denied, banner: banner) + + #expect(deviceHelper.reportedAdConsent == ReportedAdConsent( + adUserData: "granted", + adPersonalization: "denied", + source: "tcf" + )) + withExtendedLifetime(dependencyContainer) {} + } + + @Test func trackingNone_deniesBothOverTheBanner() async { + let banner = BannerDefaults(gdprApplies: 1, purposes: "1111111111") + let dependencyContainer = DependencyContainer(cache: CacheMock()) + let deviceHelper = makeDeviceHelper(dependencyContainer, attStatus: .authorized, banner: banner) + dependencyContainer.configManager.options.eventTrackingBehavior = .none + + #expect(deviceHelper.reportedAdConsent == ReportedAdConsent( + adUserData: "denied", + adPersonalization: "denied", + source: "tcf" + )) + withExtendedLifetime(dependencyContainer) {} + } + + @Test func bannerChange_republishesOnceWithTheNewConsent() async { + let banner = BannerDefaults() + let (superwall, dependencyContainer, recorder) = makeSuperwall( + attStatus: ATTStatusBox(.authorized), + banner: banner + ) + let notificationCenter = NotificationCenter() + let observer = TCFConsentObserver( + defaults: banner.defaults, + notificationCenter: notificationCenter, + onChange: { await superwall.republishDeviceAttributesIfAdConsentChanged() } + ) + let initial = await dependencyContainer.makeSessionDeviceAttributes() + await superwall.track(InternalSuperwallEvent.DeviceAttributes(deviceAttributes: initial)) + #expect(recorder.sent.last?["adConsentSource"] as? String == "default") + + // An unrelated defaults write sends nothing. + banner.defaults.set("value", forKey: "unrelated") + notificationCenter.post(name: UserDefaults.didChangeNotification, object: banner.defaults) + try? await Task.sleep(nanoseconds: 300_000_000) + await drainAdConsentUpdates(superwall) + #expect(recorder.sent.count == 1) + + // The user answers the banner, denying personalization. + banner.set(gdprApplies: 1, purposes: "1101111111") + notificationCenter.post(name: UserDefaults.didChangeNotification, object: banner.defaults) + await waitUntil { recorder.sent.count > 1 } + try? await Task.sleep(nanoseconds: 300_000_000) + await drainAdConsentUpdates(superwall) + #expect(recorder.sent.count == 2) + #expect(recorder.sent.last?["adUserDataConsent"] as? String == "granted") + #expect(recorder.sent.last?["adPersonalizationConsent"] as? String == "denied") + #expect(recorder.sent.last?["adConsentSource"] as? String == "tcf") + + // The same answer again sends nothing. + notificationCenter.post(name: UserDefaults.didChangeNotification, object: banner.defaults) + try? await Task.sleep(nanoseconds: 300_000_000) + await drainAdConsentUpdates(superwall) + #expect(recorder.sent.count == 2) + withExtendedLifetime(observer) {} + } + // MARK: - Runtime updates @Test func settingAdConsent_tracksConfigAndDeviceAttributes() async { @@ -412,22 +605,29 @@ struct AdConsentTests { /// rather than replaced: other services hold it `unowned`, so a replaced one /// would be freed under them. The caller keeps the container alive, since the /// helper only holds it `unowned`. + /// + /// It reads banner consent from `banner`, or from an empty suite, so nothing in + /// the test host's standard defaults can leak in. private func makeDeviceHelper( _ dependencyContainer: DependencyContainer, - attStatus: FakeTrackingAuthorizationStatus + attStatus: FakeTrackingAuthorizationStatus, + banner: BannerDefaults = BannerDefaults() ) -> DeviceHelper { let deviceHelper: DeviceHelper = dependencyContainer.deviceHelper deviceHelper.attStatusProvider = { attStatus.rawValue } + deviceHelper.consentDefaults = banner.defaults return deviceHelper } /// A `Superwall` whose device helper reads `attStatus`, with a delegate that /// records every device-attributes event it sends. private func makeSuperwall( - attStatus: ATTStatusBox + attStatus: ATTStatusBox, + banner: BannerDefaults = BannerDefaults() ) -> (Superwall, DependencyContainer, MockSuperwallDelegate) { let dependencyContainer = DependencyContainer(cache: CacheMock()) dependencyContainer.deviceHelper.attStatusProvider = { attStatus.value.rawValue } + dependencyContainer.deviceHelper.consentDefaults = banner.defaults let superwall = Superwall(dependencyContainer: dependencyContainer) let recorder = MockSuperwallDelegate() dependencyContainer.delegateAdapter.swiftDelegate = recorder @@ -458,6 +658,36 @@ struct AdConsentTests { } } +struct BannerFixture: Sendable, CustomTestStringConvertible { + let gdprApplies: Int? + let purposes: String? + + var testDescription: String { + "gdprApplies \(gdprApplies.map(String.init) ?? "missing"), purposes \(purposes.map { "\"\($0)\"" } ?? "missing")" + } +} + +/// A private `UserDefaults` suite standing in for what a consent banner stores, +/// removed when the test is done with it. +final class BannerDefaults: @unchecked Sendable { + let defaults: UserDefaults + private let suiteName = "AdConsentTests.\(UUID().uuidString)" + + init(gdprApplies: Int? = nil, purposes: String? = nil) { + defaults = UserDefaults(suiteName: suiteName)! + set(gdprApplies: gdprApplies, purposes: purposes) + } + + func set(gdprApplies: Int?, purposes: String?) { + defaults.set(gdprApplies, forKey: TCFConsent.gdprAppliesKey) + defaults.set(purposes, forKey: TCFConsent.purposeConsentsKey) + } + + deinit { + defaults.removePersistentDomain(forName: suiteName) + } +} + private final class DrainedFlag: @unchecked Sendable { private let lock = NSLock() private var _isSet = false From de211ff18351b2d3d9dd79d6cba92157d2fb3f74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:15:33 +0200 Subject: [PATCH 14/15] Reconcile ad consent after every device attributes send A send reads its ad consent before awaiting the rest of the device attributes, so a banner (or ATT) answer that arrived in between was lost: before the first send there was nothing to compare against, and the in-flight send then went out with the stale value. Every recorded send now queues the serialized republish-if-changed, which compares the current consent and source with what was just sent and does nothing when they match. Also corrects the doc comment on `republishDeviceAttributesIfAdConsentChanged()`. Co-Authored-By: Claude Opus 5.5 --- .../Internal Tracking/Tracking.swift | 2 + .../Network/Device Helper/DeviceHelper.swift | 4 + Sources/SuperwallKit/Superwall.swift | 52 +++++++---- .../Config/AdConsentTests.swift | 90 +++++++++++++++++++ 4 files changed, 132 insertions(+), 16 deletions(-) diff --git a/Sources/SuperwallKit/Analytics/Internal Tracking/Tracking.swift b/Sources/SuperwallKit/Analytics/Internal Tracking/Tracking.swift index 748ed3d837..a16aa54731 100644 --- a/Sources/SuperwallKit/Analytics/Internal Tracking/Tracking.swift +++ b/Sources/SuperwallKit/Analytics/Internal Tracking/Tracking.swift @@ -73,6 +73,8 @@ extension Superwall { dependencyContainer.deviceHelper.recordPublishedDeviceAttributes( deviceAttributes.deviceAttributes ) + // Catch a consent change that landed while these attributes were being built. + reconcileAdConsentAfterPublish() } if event.canImplicitlyTriggerPaywall { diff --git a/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift index 3da473a943..838ecd41ec 100644 --- a/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift +++ b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift @@ -21,6 +21,9 @@ class DeviceHelper { var attStatusProvider: () -> Int? /// Where a consent banner stores its answer. Settable so tests can use their own. var consentDefaults: UserDefaults = .standard + /// Runs while device attributes are being built, after their ad consent is read. + /// For tests, to change consent mid-build. + var afterAdConsentSnapshot: (() async -> Void)? /// The ad consent attributes in the last device attributes sent, or `nil` before /// the first send. Guarded by `adConsentLock`. private var publishedAdConsent: ReportedAdConsent? @@ -1099,6 +1102,7 @@ class DeviceHelper { // the same way by hand — keep them in step. let traits = currentUITraits let adConsent = reportedAdConsent + await afterAdConsentSnapshot?() let template = DeviceTemplate( publicApiKey: storage.apiKey, diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index cdafddcb0e..1a2dd850df 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -1038,9 +1038,9 @@ public final class Superwall: NSObject, ObservableObject { } } - /// Re-sends device attributes if the ad personalization consent they report has + /// Re-sends device attributes if the ad consent they report, or its source, has /// changed since they were last sent, such as after the user answers the ATT - /// prompt mid-session. Never prompts. Returns whether it sent them. + /// prompt or a consent banner mid-session. Never prompts. Returns whether it sent them. /// /// Goes through the same queue as ``adConsent`` assignments, so it can't overtake /// one. If an assignment is made while it waits, it defers to that. @@ -1054,22 +1054,42 @@ public final class Superwall: NSObject, ObservableObject { /// its source differs from what was last sent. Returns whether it sent them. @discardableResult func republishDeviceAttributes(onlyIfAdConsentChanged: Bool) async -> Bool { - let generation = adConsentUpdates.currentGeneration return await withCheckedContinuation { continuation in - adConsentUpdates.enqueue { [weak self] in - guard - let self, - self.adConsentUpdates.isCurrent(generation), - !onlyIfAdConsentChanged - || self.dependencyContainer.deviceHelper.adConsentNeedsRepublish() - else { - continuation.resume(returning: false) - return - } - let deviceAttributes = await self.dependencyContainer.makeSessionDeviceAttributes() - await self.track(InternalSuperwallEvent.DeviceAttributes(deviceAttributes: deviceAttributes)) - continuation.resume(returning: true) + enqueueDeviceAttributesRepublish(onlyIfAdConsentChanged: onlyIfAdConsentChanged) { + continuation.resume(returning: $0) + } + } + } + + /// Queues a republish-if-changed straight away, without waiting for it. + /// + /// Called after every device attributes send is recorded: a send builds its + /// attributes before it's tracked, so a consent change in between (an ATT or + /// banner answer) would otherwise only be checked against nothing, or against + /// the older send, and be lost. When nothing changed it does nothing, so the + /// republish it may track can't start a loop. + func reconcileAdConsentAfterPublish() { + enqueueDeviceAttributesRepublish(onlyIfAdConsentChanged: true) { _ in } + } + + private func enqueueDeviceAttributesRepublish( + onlyIfAdConsentChanged: Bool, + completion: @escaping @Sendable (Bool) -> Void + ) { + let generation = adConsentUpdates.currentGeneration + adConsentUpdates.enqueue { [weak self] in + guard + let self, + self.adConsentUpdates.isCurrent(generation), + !onlyIfAdConsentChanged + || self.dependencyContainer.deviceHelper.adConsentNeedsRepublish() + else { + completion(false) + return } + let deviceAttributes = await self.dependencyContainer.makeSessionDeviceAttributes() + await self.track(InternalSuperwallEvent.DeviceAttributes(deviceAttributes: deviceAttributes)) + completion(true) } } diff --git a/Tests/SuperwallKitTests/Config/AdConsentTests.swift b/Tests/SuperwallKitTests/Config/AdConsentTests.swift index 757f65ab4b..09992fb544 100644 --- a/Tests/SuperwallKitTests/Config/AdConsentTests.swift +++ b/Tests/SuperwallKitTests/Config/AdConsentTests.swift @@ -304,6 +304,8 @@ struct AdConsentTests { ) let initial = await dependencyContainer.makeSessionDeviceAttributes() await superwall.track(InternalSuperwallEvent.DeviceAttributes(deviceAttributes: initial)) + // Let the reconcile that follows every send settle before the test changes anything. + await drainAdConsentUpdates(superwall) #expect(recorder.sent.last?["adConsentSource"] as? String == "default") // An unrelated defaults write sends nothing. @@ -332,6 +334,54 @@ struct AdConsentTests { withExtendedLifetime(observer) {} } + @Test(arguments: [true, false]) + func bannerChangeDuringTheFirstUpload_isSentOnceItFinishes(bannerChanges: Bool) async { + let banner = BannerDefaults() + let (superwall, dependencyContainer, recorder) = makeSuperwall( + attStatus: ATTStatusBox(.authorized), + banner: banner + ) + let notificationCenter = NotificationCenter() + let observer = TCFConsentObserver( + defaults: banner.defaults, + notificationCenter: notificationCenter, + onChange: { await superwall.republishDeviceAttributesIfAdConsentChanged() } + ) + // Hold the first build after it has read the (default) consent. + let gate = SnapshotGate() + dependencyContainer.deviceHelper.afterAdConsentSnapshot = { await gate.pass() } + + let firstUpload = Task { + let attributes = await dependencyContainer.makeSessionDeviceAttributes() + await superwall.track(InternalSuperwallEvent.DeviceAttributes(deviceAttributes: attributes)) + } + await gate.waitUntilEntered() + + if bannerChanges { + // Nothing has been sent yet, so this alone can't republish anything. + banner.set(gdprApplies: 1, purposes: "0000000000") + notificationCenter.post(name: UserDefaults.didChangeNotification, object: banner.defaults) + try? await Task.sleep(nanoseconds: 200_000_000) + #expect(recorder.sent.isEmpty) + } + + await gate.release() + await firstUpload.value + try? await Task.sleep(nanoseconds: 300_000_000) + await drainAdConsentUpdates(superwall) + + #expect(recorder.sent.first?["adConsentSource"] as? String == "default") + if bannerChanges { + #expect(recorder.sent.count == 2) + #expect(recorder.sent.last?["adUserDataConsent"] as? String == "denied") + #expect(recorder.sent.last?["adPersonalizationConsent"] as? String == "denied") + #expect(recorder.sent.last?["adConsentSource"] as? String == "tcf") + } else { + #expect(recorder.sent.count == 1) + } + withExtendedLifetime(observer) {} + } + // MARK: - Runtime updates @Test func settingAdConsent_tracksConfigAndDeviceAttributes() async { @@ -379,6 +429,8 @@ struct AdConsentTests { // The initial upload reports the option, since ATT hasn't been asked yet. let initial = await dependencyContainer.makeSessionDeviceAttributes() await superwall.track(InternalSuperwallEvent.DeviceAttributes(deviceAttributes: initial)) + // Let the reconcile that follows every send settle before the test changes anything. + await drainAdConsentUpdates(superwall) #expect(recorder.sent.count == 1) #expect(recorder.sent.last?["adPersonalizationConsent"] as? String == "granted") @@ -407,6 +459,8 @@ struct AdConsentTests { // `.all` sends granted. let initial = await dependencyContainer.makeSessionDeviceAttributes() await superwall.track(InternalSuperwallEvent.DeviceAttributes(deviceAttributes: initial)) + // Let the reconcile that follows every send settle before the test changes anything. + await drainAdConsentUpdates(superwall) #expect(recorder.sent.last?["adPersonalizationConsent"] as? String == "granted") // Opted out, the user denies tracking. Activation mustn't treat that as sent. @@ -442,6 +496,8 @@ struct AdConsentTests { // Both granted are sent. let initial = await dependencyContainer.makeSessionDeviceAttributes() await superwall.track(InternalSuperwallEvent.DeviceAttributes(deviceAttributes: initial)) + // Let the reconcile that follows every send settle before the test changes anything. + await drainAdConsentUpdates(superwall) #expect(recorder.sent.last?["adUserDataConsent"] as? String == "granted") // Opted out, only ad user data is denied. That doesn't change personalization, @@ -471,6 +527,8 @@ struct AdConsentTests { // Granted is sent, then the ATT denial is queued and recorded as sent. let initial = await dependencyContainer.makeSessionDeviceAttributes() await superwall.track(InternalSuperwallEvent.DeviceAttributes(deviceAttributes: initial)) + // Let the reconcile that follows every send settle before the test changes anything. + await drainAdConsentUpdates(superwall) attStatus.value = .denied #expect(await superwall.republishDeviceAttributesIfAdConsentChanged() == true) #expect(await queue.queuedEventNames.contains("device_attributes")) @@ -519,6 +577,8 @@ struct AdConsentTests { ) let initial = await dependencyContainer.makeSessionDeviceAttributes() await superwall.track(InternalSuperwallEvent.DeviceAttributes(deviceAttributes: initial)) + // Let the reconcile that follows every send settle before the test changes anything. + await drainAdConsentUpdates(superwall) // Activation without an ATT change sends nothing. The handler is awaited // directly rather than posting the app-wide notification, which would also @@ -549,6 +609,8 @@ struct AdConsentTests { let (superwall, dependencyContainer, recorder) = makeSuperwall(attStatus: attStatus) let initial = await dependencyContainer.makeSessionDeviceAttributes() await superwall.track(InternalSuperwallEvent.DeviceAttributes(deviceAttributes: initial)) + // Let the reconcile that follows every send settle before the test changes anything. + await drainAdConsentUpdates(superwall) #expect(recorder.sent.count == 1) // The user denies tracking while the request is in flight. For a @@ -688,6 +750,34 @@ final class BannerDefaults: @unchecked Sendable { } } +/// Holds the first device attributes build until released; later builds pass. +private actor SnapshotGate { + private var isEntered = false + private var isReleased = false + private var waiters: [CheckedContinuation] = [] + + func pass() async { + if isReleased { + return + } + isEntered = true + await withCheckedContinuation { waiters.append($0) } + } + + func waitUntilEntered() async { + let start = Date() + while !isEntered && Date().timeIntervalSince(start) < 5 { + try? await Task.sleep(nanoseconds: 10_000_000) + } + } + + func release() { + isReleased = true + waiters.forEach { $0.resume() } + waiters.removeAll() + } +} + private final class DrainedFlag: @unchecked Sendable { private let lock = NSLock() private var _isSet = false From 1d7552f9f9ba5568d1ab2212c7f97521381aa5e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:34:16 +0200 Subject: [PATCH 15/15] Describe what the ad consent code does, not its history Co-Authored-By: Claude Opus 5.5 --- .../Network/Device Helper/DeviceHelper.swift | 3 +-- Sources/SuperwallKit/Superwall.swift | 10 +++------- Tests/SuperwallKitTests/Config/AdConsentTests.swift | 7 ++----- 3 files changed, 6 insertions(+), 14 deletions(-) diff --git a/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift index 838ecd41ec..913ecd33d8 100644 --- a/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift +++ b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift @@ -16,8 +16,7 @@ import StoreKit class DeviceHelper { private let ipCollector: DeviceIPCollector /// Reads the ATT status without prompting, or `nil` where the OS has no such concept. - /// Settable so tests can fix the status on a container's own helper; replacing - /// the helper would leave other services' `unowned` references to it dangling. + /// Settable for tests. var attStatusProvider: () -> Int? /// Where a consent banner stores its answer. Settable so tests can use their own. var consentDefaults: UserDefaults = .standard diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index 1a2dd850df..c94b5a2386 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -1061,13 +1061,9 @@ public final class Superwall: NSObject, ObservableObject { } } - /// Queues a republish-if-changed straight away, without waiting for it. - /// - /// Called after every device attributes send is recorded: a send builds its - /// attributes before it's tracked, so a consent change in between (an ATT or - /// banner answer) would otherwise only be checked against nothing, or against - /// the older send, and be lost. When nothing changed it does nothing, so the - /// republish it may track can't start a loop. + /// Queues a republish-if-changed without waiting for it. Called after every + /// device attributes send, so consent that changed while that send was being + /// built is sent too. Does nothing when the consent is unchanged. func reconcileAdConsentAfterPublish() { enqueueDeviceAttributesRepublish(onlyIfAdConsentChanged: true) { _ in } } diff --git a/Tests/SuperwallKitTests/Config/AdConsentTests.swift b/Tests/SuperwallKitTests/Config/AdConsentTests.swift index 09992fb544..aa1c52a98f 100644 --- a/Tests/SuperwallKitTests/Config/AdConsentTests.swift +++ b/Tests/SuperwallKitTests/Config/AdConsentTests.swift @@ -662,11 +662,8 @@ struct AdConsentTests { #expect(dictionary["adPersonalizationConsent"] as? String == "granted") } - /// The container's own device helper with its ATT status fixed, since the - /// simulator's real status can't be set from a test. The helper is modified - /// rather than replaced: other services hold it `unowned`, so a replaced one - /// would be freed under them. The caller keeps the container alive, since the - /// helper only holds it `unowned`. + /// The container's own device helper with its ATT status set for the test. The + /// caller keeps the container alive, since the helper holds it `unowned`. /// /// It reads banner consent from `banner`, or from an empty suite, so nothing in /// the test host's standard defaults can leak in.