diff --git a/CHANGELOG.md b/CHANGELOG.md index e1669d3f91..04c7c426eb 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`, 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/Analytics/App Session/AppSessionManager.swift b/Sources/SuperwallKit/Analytics/App Session/AppSessionManager.swift index 688efcd9f7..03ed032848 100644 --- a/Sources/SuperwallKit/Analytics/App Session/AppSessionManager.swift +++ b/Sources/SuperwallKit/Analytics/App Session/AppSessionManager.swift @@ -19,13 +19,23 @@ 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 + /// Where app lifecycle notifications are observed. Tests pass their own. + private let notificationCenter: NotificationCenter init( configManager: ConfigManager, identityManager: IdentityManager, storage: Storage, - delegate: DeviceHelperFactory & UserAttributesPlacementFactory + 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 @@ -38,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, @@ -95,11 +105,19 @@ class AppSessionManager { @objc private func applicationDidBecomeActive() { Task { - await Superwall.shared.track(InternalSuperwallEvent.AppOpen()) - await sessionCouldRefresh() + 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/Analytics/Internal Tracking/Tracking.swift b/Sources/SuperwallKit/Analytics/Internal Tracking/Tracking.swift index 30e2b0154a..a16aa54731 100644 --- a/Sources/SuperwallKit/Analytics/Internal Tracking/Tracking.swift +++ b/Sources/SuperwallKit/Analytics/Internal Tracking/Tracking.swift @@ -52,18 +52,31 @@ 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) + // 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 + ) + // Catch a consent change that landed while these attributes were being built. + reconcileAdConsentAfterPublish() + } + if event.canImplicitlyTriggerPaywall { Task.detached { [weak self] in await self?.handleImplicitTrigger( diff --git a/Sources/SuperwallKit/Config/Options/AdConsent.swift b/Sources/SuperwallKit/Config/Options/AdConsent.swift new file mode 100644 index 0000000000..c206357151 --- /dev/null +++ b/Sources/SuperwallKit/Config/Options/AdConsent.swift @@ -0,0 +1,74 @@ +// +// 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 AdConsentStatus: 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 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 user data to ad networks for advertising. + public let adUserData: AdConsentStatus + + /// Consent for them to use that data for personalized advertising. + public let adPersonalization: AdConsentStatus + + public init( + adUserData: AdConsentStatus = .granted, + adPersonalization: AdConsentStatus = .granted + ) { + self.adUserData = adUserData + self.adPersonalization = adPersonalization + super.init() + } + + public override convenience init() { + self.init(adUserData: .granted, adPersonalization: .granted) + } + + /// 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 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? + ) -> AdConsent { + if eventTrackingBehavior == .none { + return AdConsent(adUserData: .denied, adPersonalization: .denied) + } + 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/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 aaf0277ef9..626663e11c 100644 --- a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift +++ b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift @@ -364,6 +364,30 @@ 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 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 `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() { + 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`. /// @@ -536,6 +560,9 @@ public final class SuperwallOptions: NSObject, Encodable { case shouldObservePurchases case enableExperimentalDeviceVariables case testModeBehavior + case adUserDataConsent + case adPersonalizationConsent + case isAdConsentSet } public override init() { @@ -571,6 +598,9 @@ 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) + 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 16d6105d14..913ecd33d8 100644 --- a/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift +++ b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift @@ -15,6 +15,18 @@ import StoreKit class DeviceHelper { private let ipCollector: DeviceIPCollector + /// Reads the ATT status without prompting, or `nil` where the OS has no such concept. + /// Settable for tests. + 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? + private let adConsentLock = NSLock() var localeIdentifier: String { let localeIdentifier = factory.makeLocaleIdentifier() return localeIdentifier ?? Locale.autoupdatingCurrent.identifier @@ -916,6 +928,7 @@ class DeviceHelper { & LocaleIdentifierFactory & WebEntitlementFactory & ConfigStateFactory + & OptionsFactory init( api: Api, @@ -923,10 +936,16 @@ 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 } + 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 @@ -950,6 +969,78 @@ 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 + } + + /// The ad consent device attributes would report right now. + /// + /// 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() + 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: source == .developer ? nil : attStatusProvider() + ) + return ReportedAdConsent( + adUserData: reported.adUserData.description, + adPersonalization: reported.adPersonalization.description, + source: source.rawValue + ) + } + + /// Notes the ad consent in device attributes that were just sent. + func recordPublishedDeviceAttributes(_ attributes: [String: Any]) { + guard let consent = ReportedAdConsent(attributes: attributes) else { + return + } + adConsentLock.lock() + publishedAdConsent = consent + adConsentLock.unlock() + } + + /// 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 adConsentNeedsRepublish() -> Bool { + if factory.makeSuperwallOptions().eventTrackingBehavior == .none { + return false + } + let current = reportedAdConsent + adConsentLock.lock() + defer { adConsentLock.unlock() } + guard let published = publishedAdConsent else { + return false + } + return published != current + } + deinit { for observer in traitObservers { NotificationCenter.default.removeObserver(observer) @@ -1009,6 +1100,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 adConsent = reportedAdConsent + await afterAdConsentSnapshot?() let template = DeviceTemplate( publicApiKey: storage.apiKey, @@ -1071,7 +1164,10 @@ class DeviceHelper { swiftVersion: currentSwiftVersion(), compilerVersion: currentCompilerVersion(), localResourceIds: Superwall.shared.options.localResources.keys.sorted().joined(separator: ","), - deviceId: factory.makeDeviceId() + deviceId: factory.makeDeviceId(), + adUserDataConsent: adConsent.adUserData, + adPersonalizationConsent: adConsent.adPersonalization, + adConsentSource: adConsent.source ) var deviceDictionary = template.toDictionary( 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..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 @@ -677,6 +683,9 @@ final class PaywallMessageHandler: WebEventDelegate { await Superwall.shared.track(requestedEvent) let status = await permissionHandler.requestPermission(permissionType) + if permissionType == .tracking { + await republishIfAdConsentChanged() + } // Track permission result event let resultState: InternalSuperwallEvent.PermissionState = 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..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 @@ -69,6 +69,9 @@ struct DeviceTemplate: Codable { var compilerVersion: String var localResourceIds: String 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/Storage/PlacementsQueue.swift b/Sources/SuperwallKit/Storage/PlacementsQueue.swift index f0cdb8e6ee..03a284e00d 100644 --- a/Sources/SuperwallKit/Storage/PlacementsQueue.swift +++ b/Sources/SuperwallKit/Storage/PlacementsQueue.swift @@ -73,14 +73,22 @@ 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 + } + + /// The names of the events waiting to be flushed. For tests. + var queuedEventNames: [String] { + elements.compactMap { $0["event_name"].string } } func setTrackingBehavior(_ behavior: EventTrackingBehavior) { diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index 3a510a1c82..c94b5a2386 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 oldValue = options.eventTrackingBehavior options.eventTrackingBehavior = newValue if newValue != .none { @@ -84,6 +85,14 @@ public final class Superwall: NSObject, ObservableObject { Task { await dependencyContainer.placementsQueue.setTrackingBehavior(newValue) + // 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) + } } let behavior = newValue @@ -106,6 +115,44 @@ 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 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 `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`. + public var adConsent: AdConsent { + get { + return options.adConsent + } + set { + options.adConsent = newValue + + let generation = adConsentUpdates.nextGeneration() + let configAttributes = dependencyContainer.makeConfigAttributes() + 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)) + } + } + } + /// 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: @@ -425,6 +472,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 @@ -988,6 +1038,67 @@ public final class Superwall: NSObject, ObservableObject { } } + /// 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 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. + @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 reported ad consent or + /// its source differs from what was last sent. Returns whether it sent them. + @discardableResult + func republishDeviceAttributes(onlyIfAdConsentChanged: Bool) async -> Bool { + return await withCheckedContinuation { continuation in + enqueueDeviceAttributesRepublish(onlyIfAdConsentChanged: onlyIfAdConsentChanged) { + continuation.resume(returning: $0) + } + } + } + + /// 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 } + } + + 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) + } + } + + /// 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/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index ba50b72473..a3022c43fb 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 */; }; @@ -84,6 +85,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 +151,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 */; }; @@ -674,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 */; }; @@ -829,6 +833,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 = ""; }; @@ -882,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 = ""; }; @@ -1176,6 +1182,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 = ""; }; @@ -1351,6 +1358,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 +3357,7 @@ D554340BB6652F5FA1F21FF8 /* Config */ = { isa = PBXGroup; children = ( + 2ABE060CEC937FA10038A356 /* AdConsentTests.swift */, 97D7F499B2CBFFF0A61F8D72 /* ConfigLogicTests.swift */, 779C974DE83DCC78D75A59DC /* ConfigManagerEarlyPublishTests.swift */, DAAE50F011668C1D62B86751 /* ConfigManagerMock.swift */, @@ -3470,10 +3479,13 @@ E7D208EAB24A633EB74B1E31 /* Options */ = { isa = PBXGroup; children = ( + DE25827D6C0FFD2E3AEB5E1A /* AdConsent.swift */, + A6F00A06AEE73D087036B81C /* AdConsentUpdateQueue.swift */, 23307BBFD80385233DDD4C43 /* AssetResource.swift */, 93D8033AAF5549E30ACDA3EA /* EventTrackingBehavior.swift */, B1A64CCBCB23CC1715DF79AC /* PaywallOptions.swift */, CFDA311A030FDFC45AEE248A /* SuperwallOptions.swift */, + 4440A6832E1195B9C1C8633A /* TCFConsent.swift */, ); path = Options; sourceTree = ""; @@ -3760,6 +3772,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 +3972,8 @@ 2C7867867DDAD83E5856ECC9 /* ASN1Types.swift in Sources */, 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 */, @@ -4372,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 new file mode 100644 index 0000000000..aa1c52a98f --- /dev/null +++ b/Tests/SuperwallKitTests/Config/AdConsentTests.swift @@ -0,0 +1,825 @@ +// +// 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 init_defaultsUnspecifiedPurposesToGranted() { + let consent = AdConsent(adUserData: .denied) + #expect(consent.adUserData == .denied) + #expect(consent.adPersonalization == .granted) + } + + @Test func consentStatus_descriptionsMatchTheServerContract() { + #expect(AdConsentStatus.granted.description == "granted") + #expect(AdConsentStatus.denied.description == "denied") + } + + @Test func templateDevice_reportsGrantedByDefault() async { + let dependencyContainer = DependencyContainer(cache: CacheMock()) + let deviceHelper = makeDeviceHelper(dependencyContainer, attStatus: .notDetermined) + + let template = await deviceHelper.getTemplateDevice() + + #expect(template["adUserDataConsent"] as? String == "granted") + #expect(template["adPersonalizationConsent"] as? String == "granted") + withExtendedLifetime(dependencyContainer) {} + } + + @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 deviceHelper.getTemplateDevice() + #expect(template["adUserDataConsent"] as? String == "denied") + #expect(template["adPersonalizationConsent"] as? String == "granted") + + superwall.adConsent = AdConsent(adUserData: .granted, adPersonalization: .denied) + + 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 { + 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 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) + withExtendedLifetime(dependencyContainer) {} + } + + @Test(arguments: [FakeTrackingAuthorizationStatus.denied, .restricted]) + func attNotAllowed_deniesDefaultPersonalizationOnly( + _ attStatus: FakeTrackingAuthorizationStatus + ) async { + let dependencyContainer = DependencyContainer(cache: CacheMock()) + let deviceHelper = makeDeviceHelper(dependencyContainer, attStatus: attStatus) + let options = dependencyContainer.configManager.options + + 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 + ) 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") + withExtendedLifetime(dependencyContainer) {} + } + + @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, attStatus: nil) + + #expect(reported.adUserData == .denied) + #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)) + // 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. + 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) {} + } + + @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 { + 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") + await drainAdConsentUpdates(superwall) + } + + @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" + ) + await drainAdConsentUpdates(superwall) + } + + // 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)) + // 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") + + // 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) + await drainAdConsentUpdates(superwall) + withExtendedLifetime(dependencyContainer) {} + } + + @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)) + // 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. + 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) + await drainAdConsentUpdates(superwall) + withExtendedLifetime(dependencyContainer) {} + } + + @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)) + // 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, + // 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") + await drainAdConsentUpdates(superwall) + 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)) + // 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")) + + // 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) + + 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) + await drainAdConsentUpdates(superwall) + } + + @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() + }, + // 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)) + // 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 + // wake managers from suites running in parallel. + await appSessionManager.didBecomeActive() + #expect(recorder.sent.count == 1) + + // Activation after the user denied tracking sends exactly one update. + attStatus.value = .denied + await appSessionManager.didBecomeActive() + #expect(recorder.sent.count == 2) + #expect(recorder.sent.last?["adPersonalizationConsent"] as? String == "denied") + withExtendedLifetime((appSessionManager, sessionDelegate, dependencyContainer)) {} + await drainAdConsentUpdates(superwall) + } + + @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)) + // 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 + // 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) {} + await drainAdConsentUpdates(superwall) + withExtendedLifetime(dependencyContainer) {} + } + + @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") + } + + /// 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. + private func makeDeviceHelper( + _ dependencyContainer: DependencyContainer, + 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, + 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 + 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 + ) async { + let start = Date() + while !condition() && Date().timeIntervalSince(start) < timeout { + try? await Task.sleep(nanoseconds: 50_000_000) + } + } +} + +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) + } +} + +/// 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 + + 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 + + init(_ value: FakeTrackingAuthorizationStatus) { + _value = value + } + + var value: FakeTrackingAuthorizationStatus { + get { lock.withLock { _value } } + set { lock.withLock { _value = newValue } } + } +} + +extension MockSuperwallDelegate { + /// The attributes of every `device_attributes` event, in the order they were tracked. + var sent: [[String: Any]] { + eventsReceived.compactMap { event in + if case let .deviceAttributes(attributes) = event { + return attributes + } + return nil + } + } + + /// 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/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 } } 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]) {