Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
30 changes: 24 additions & 6 deletions Sources/SuperwallKit/Analytics/App Session/AppSessionManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
15 changes: 14 additions & 1 deletion Sources/SuperwallKit/Analytics/Internal Tracking/Tracking.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Comment thread
yusuftor marked this conversation as resolved.
// Catch a consent change that landed while these attributes were being built.
reconcileAdConsentAfterPublish()
}

if event.canImplicitlyTriggerPaywall {
Task.detached { [weak self] in
await self?.handleImplicitTrigger(
Expand Down
74 changes: 74 additions & 0 deletions Sources/SuperwallKit/Config/Options/AdConsent.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
58 changes: 58 additions & 0 deletions Sources/SuperwallKit/Config/Options/AdConsentUpdateQueue.swift
Original file line number Diff line number Diff line change
@@ -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<Job>.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)
}
}
30 changes: 30 additions & 0 deletions Sources/SuperwallKit/Config/Options/SuperwallOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
///
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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] {
Expand Down
Loading
Loading