Repository navigation
Add ad consent option for Google Ads conversion uploads - #544
Conversation
Adds `SuperwallOptions.adConsent` and `Superwall.shared.adConsent`
(`AdConsent` with `adUserData` and `adPersonalization`, each a
`ConsentStatus` of granted or denied, both granted by default). The
SDK reports them as the `adUserDataConsent` and
`adPersonalizationConsent` device attributes ("granted"/"denied"),
which the server forwards to Google Ads with uploaded conversions.
Both report denied while `eventTrackingBehavior` is `.none`.
Setting `Superwall.shared.adConsent` re-sends config and device
attributes so the change lands promptly.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Its properties are now `let`, so consent only changes by assigning a new `AdConsent`, which always goes through the setter that re-sends device attributes. Matches the Android data class. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`adPersonalizationConsent` is reported as "denied" when the ATT status is denied or restricted. An undetermined status (or no ATT at all) leaves it to the option, and `adUserDataConsent` ignores ATT. Applied when building device attributes, so the stored option and `config_attributes` keep the developer's values. The status is read through the existing `TrackingManagerProxy`, which never prompts and reports notDetermined when AppTrackingTransparency isn't linked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Maple review🟡 Confidence 5/10 · needs attention Adds immutable ad consent options and reports effective consent in device attributes while preserving raw config values. ATT changes need to refresh the server-side consent snapshot before merge.
Findings🟠 Warning · F1 ·
|
| Change | Kind | Observable | Evidence |
|---|---|---|---|
| Superwall.adConsent runtime updates | background | no | Follows existing Task/track setters; emits structured analytics through Tracking.swift, but no tracing SDK or span helper was found. |
426667a · Updated on every push. Reply "won't fix" to dismiss a finding, or mention @maple-review-bot to ask about one.
📚 Docs requiredWhat changed for customers: apps can now report a user's ad consent with Why this needs docs: adds a new Coverage today: absent for Without a docs page this change also gets no changelog entry: Prompt for the docs agent — run in
|
There was a problem hiding this comment.
ℹ️ No critical issues — two rough edges worth a look inline.
Reviewed changes
I reviewed the full PR: the new ad consent option, how it's reported in config and device attributes, and the ATT/eventTrackingBehavior rules.
- New public
AdConsent/ConsentStatustypes: an immutableNSObjectwithadUserData/adPersonalization, both defaulting to.granted, bridged to Objective‑C asSWKAdConsent/SWKConsentStatus. - Option and runtime setter:
SuperwallOptions.adConsentandSuperwall.shared.adConsent. The setter re-sends config attributes, then aDeviceAttributesevent, the same waysetInterfaceStyledoes. - Reported device attributes:
getTemplateDevice()now emitsadUserDataConsent/adPersonalizationConsent..nonetracking denies both, and an ATT status of denied or restricted denies personalization. The raw values still go intoconfig_attributes. - ATT injection:
DeviceHelpernow also requiresOptionsFactoryand takes anattStatusProvider, so tests can fix the ATT status.
claude-opus-5.5 | 𝕏
If device attributes went out while ATT was undetermined and the user then denied tracking in the same session, the server kept "granted" personalization until the next session. The SDK now remembers the `adPersonalizationConsent` it last sent and, on app activation and after its own tracking-permission request, re-sends device attributes once if the value it would report has changed. It never prompts or polls. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Maple review🟡 Confidence 6/10 · needs attention This revision republishes device attributes when ATT changes on app activation or after a paywall tracking-permission request. The incremental changes are safe to merge; direct permission-hook test coverage remains incomplete.
What was checked
Observability coverage: 0 of 1 changes observable
|
- Rename `ConsentStatus` to `AdConsentStatus` so it doesn't clash with FirebaseAnalytics' `ConsentStatus`. The Objective-C name stays `SWKConsentStatus`. - Send `adConsent` updates through one serial queue with a generation number, dropping a superseded snapshot before it's tracked, so the last assignment always wins. The ATT republish uses the same queue. - Test that the setter tracks `config_attributes` and `device_attributes` with the new values, and that the last of two rapid assignments wins. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Warning The review of |
There was a problem hiding this comment.
ℹ️ The ATT change is now re-sent mid-session. One edge case is left inline.
Reviewed changes
I reviewed the one commit since the last review (9747972). It re-sends device attributes when an ATT change alters the reported adPersonalizationConsent.
- Tracked last-sent consent:
DeviceHelpernow stores theadPersonalizationConsentfrom eachDeviceAttributesevent that passes throughtrack().claimAdPersonalizationConsentRepublish()compares it with the current reported value and claims it under a lock, so callers running at the same time only send once. - Re-send triggers:
republishDeviceAttributesIfAdConsentChanged()runs on every app activation, after the session check, and after the SDK's own.trackingpermission request. It never shows a prompt. - Extracted
reportedAdConsent:getTemplateDevice()and the change check now use the same computed value. - Tests: added tests for a mid-session ATT denial, for no re-send before the first upload, and for re-sends on activation only when something changed.
claude-opus-5.5 | 𝕏
`PaywallMessageHandler` takes the republish as an injectable closure (defaulting to `Superwall.shared`) so the test can drive it. A tracking request that changes ATT from undetermined to denied after an initial upload sends exactly one device_attributes update with personalization denied; a non-tracking request sends none. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Maple review🟢 Confidence 10/10 · safe to merge The revision makes the paywall consent-republish hook injectable and adds tracking-versus-notification regression coverage. It preserves the production callback and permission-result ordering; the changes since the previous review are safe to merge. What was checked
|
There was a problem hiding this comment.
ℹ️ The rename and the serialized update queue look correct. There's one small doc nit inline. The
.noneedge case from the previous review (onTracking.swift) is still open.
Reviewed changes
I reviewed the one commit since the last review (53a0e23). It renames the status type and puts every ad consent device-attribute send on one ordered queue.
- Renamed
ConsentStatustoAdConsentStatus: this avoids clashing withFirebaseAnalytics.ConsentStatus. The Objective‑C name staysSWKConsentStatus. - Added
AdConsentUpdateQueue: a single consumer that runs jobs from anAsyncStreamone at a time, plus a lock-guarded generation counter. EachadConsentassignment starts a new generation. A job checks its generation before sending config attributes and again before sending device attributes, so an older snapshot can't land after a newer one. - Routed the ATT re-send through the same queue:
republishDeviceAttributesIfAdConsentChanged()now waits on a continuation that its job always resumes exactly once. It skips the send if an assignment arrived while it was waiting, because that assignment sends a fresh snapshot. - Tests: added tests checking that an assignment sends both
config_attributesanddevice_attributes, and that the last of two rapid assignments wins. The custom recorder was replaced withMockSuperwallDelegate, which now keeps the fullSuperwallEventInfotoo.
claude-opus-5.5 | 𝕏
There was a problem hiding this comment.
ℹ️ No new issues in this commit. Two earlier threads are still open and unchanged: the misplaced doc comment on
republishDeviceAttributesIfAdConsentChanged()(Superwall.swift) and the.noneedge case (Tracking.swift).
Reviewed changes
I reviewed the one commit since the last review (a55a7ac). It adds a test seam and a test for the paywall's tracking-permission re-send hook.
- Injected the re-send hook into
PaywallMessageHandler: a newrepublishIfAdConsentChangedclosure parameter defaults toSuperwall.shared.republishDeviceAttributesIfAdConsentChanged(), so production behavior and existing call sites don't change. - Added
paywallPermissionRequest_republishesOnlyForTracking: changes ATT while the request is in flight, then checks that a.trackingrequest re-sendsadPersonalizationConsent: deniedbeforepermission_resultreaches the web view, and that a.notificationrequest sends nothing. - Extended
FakePermissionHandler: anonRequestcallback runs while a request is in flight, so the test can change what the user answered.
claude-opus-5.5 | 𝕏
The ad personalization value last sent was recorded even when the placements queue dropped the event under `.none`, so a consent change made while opted out was never re-sent after opting back in. It's now recorded only when the queue accepts the event and tracking isn't `.none`, the republish check no longer marks anything sent and does nothing under `.none`, and switching away from `.none` runs the serialized republish-if-changed once the queue accepts events again. Also moves `@discardableResult` below the doc comment on `republishDeviceAttributesIfAdConsentChanged()`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Maple review🟡 Confidence 6/10 · needs attention The update records consent only after queue acceptance and republishes changed ATT consent when tracking resumes. The resume path still misses user-data-only consent withdrawals and needs correction before merging.
Findings🟠 Warning · F2 ·
|
| Change | Kind | Observable | Evidence |
|---|---|---|---|
| Consent catch-up when tracking resumes | background work | yes | Uses the existing track(DeviceAttributes) path, which emits structured Logger.debug output and queues the analytics event. |
8ed70bf · Updated on every push. Reply "won't fix" to dismiss a finding, or mention @maple-review-bot to ask about one.
There was a problem hiding this comment.
ℹ️ The
.noneedge case from the last review is fixed for ATT changes. One gap is left inline: the opt-in catch-up only checks personalization.
Reviewed changes
I reviewed the one commit since the last review (8ed70bf). It changes when device attributes count as sent, and catches up when tracking resumes.
- Recorded only queued sends:
PlacementsQueue.enqueuenow returns whether it accepted the event.track()records the publishedadPersonalizationConsentonly if the queue accepted it andeventTrackingBehaviorisn't.none. - Made the republish check read-only:
claimAdPersonalizationConsentRepublish()becameadPersonalizationConsentNeedsRepublish(). It no longer marks the value as sent, and returnsfalseunder.none.AdConsentUpdateQueuenow prevents double sends. - Catch-up on opt-in: leaving
.nonethrough theeventTrackingBehaviorsetter runsrepublishDeviceAttributesIfAdConsentChanged()once the queue accepts events again. - Doc comment fix:
@discardableResultnow sits below the doc comment. - Tests: added
attChangeWhileOptedOut_republishesOnceTrackingResumes, which covers the exact sequence from the earlier thread.
claude-opus-5.5 | 𝕏
An `adConsent` assignment made while opted out that didn't change personalization (e.g. only `adUserData: .denied`) never reached device_attributes, because leaving `.none` only republished when personalization changed. It now always re-sends them through the serialized ad consent queue, matching Android. The ATT-change path is unchanged. Also fixes the CI crash: the activation test posted the app-wide didBecomeActive notification, which woke AppSessionManagers from suites running in parallel whose unowned dependencies were already gone. The activation handler is now an awaitable `didBecomeActive()` that the test calls directly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Maple review🟢 Confidence 9/10 · safe to merge The revision re-sends device attributes when tracking resumes, including user-data consent changes made while opted out. It also makes activation tests await the handler directly. No new defects found in the three files changed since the previous review. What was checked
Observability coverage: 1 of 1 changes observable
|
There was a problem hiding this comment.
✅ No new issues found. The opt-in gap from the last review is closed: leaving
.nonenow always re-sends device attributes.
Reviewed changes
I reviewed the one commit since the last review (5757f03).
- Unconditional re-send on opt-in: the
eventTrackingBehaviorsetter now calls the newrepublishDeviceAttributes(onlyIfAdConsentChanged: false)when it leaves.none. AnadConsentassignment made while opted out (e.g.adUserData: .denied) now reachesdevice_attributes. The job still runs onAdConsentUpdateQueueand capturescurrentGenerationafter any opted-out assignment, so it isn't skipped wrongly and still defers to a newer one. - Split out
republishDeviceAttributes(onlyIfAdConsentChanged:):republishDeviceAttributesIfAdConsentChanged()now wraps it withtrue, so the ATT activation and permission paths behave the same as before. - Extracted
AppSessionManager.didBecomeActive(): the notification handler now just awaits this method in aTask. Event order is unchanged:AppOpen, then the session check, then the republish. - Tests: added
adConsentSetWhileOptedOut_isSentOnceTrackingResumes. The activation test now awaitsdidBecomeActive()directly rather than posting the app-wide notification, so it no longer needs the sleeps.
claude-opus-5.5 | 𝕏
AppSessionManager observes lifecycle notifications on an injectable center that defaults to `.default`, so production is unchanged. The ad consent activation test passes a private one, so didBecomeActive posts from other suites can't start handlers that outlive it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Maple review🟢 Confidence 9/10 · safe to merge The latest revision injects What was checked
|
The CI crash was `IdentityManager._mergeUserAttributes` reading its `unowned` DeviceHelper after it was freed. The ad consent tests swapped `dependencyContainer.deviceHelper` for one with a stubbed ATT status, so the container's original helper was released while IdentityManager's queued alias/seed merge still held it unowned. - `DeviceHelper.attStatusProvider` is now settable, and the tests stub it on the container's own helper instead of replacing the helper. - Adds an internal `waitForPendingAdConsentUpdates()`, and each test drains the ad consent work it started (bounded, so a stalled StoreKit or Core Data call fails the test instead of hanging the run) and keeps its container alive until then. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Maple review🟢 Confidence 10/10 · safe to merge The follow-up preserves the container-owned device helper and drains queued consent updates before tests finish. These changes address the test lifetime crash without altering consent reporting behavior; no new merge blockers found.
What was checked
|
`PlacementsQueue.setTrackingBehavior` discards every queued event for any behavior other than `.all`, so a device_attributes update already recorded as sent (e.g. an ATT denial) was lost when switching `.all` to `.superwallOnly` before a flush. Any change to a behavior other than `.none` (and re-setting `.superwallOnly`) now re-sends device attributes through the serialized ad consent queue once the queue has the new behavior, generalizing the leaving-`.none` path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Maple review🟢 Confidence 8/10 · likely safe to merge The latest changes re-send device attributes after tracking-behavior changes, preserving queued consent updates when switching to
Before merge
What was checked
Observability coverage: 1 of 1 changes observable
|
There was a problem hiding this comment.
✅ No new issues found. Switching tracking behavior no longer loses a queued ad consent update.
Reviewed changes
I reviewed the two commits since the last review (91aad74, 1750d2d).
- Re-sent device attributes after any tracking-behavior change: the
eventTrackingBehaviorsetter now re-sends unconditionally on every change to a value other than.none, unless it's.all→.all. That covers the case wherePlacementsQueue.setTrackingBehaviorthrows away a queueddevice_attributesevent that was already recorded as sent (e.g..all→.superwallOnly)..superwallOnly→.all, or setting.superwallOnlytwice, sends one extradevice_attributesevent, which does no harm. - Added a test seam and test:
PlacementsQueue.queuedEventNameslets the newswitchingToSuperwallOnly_resendsDiscardedDeviceAttributestest check that the denial is queued, gets thrown away by the switch, and is queued again exactly once. - Narrowed the wording:
AdConsent,SuperwallOptions.adConsent,Superwall.adConsentand the CHANGELOG now say that consent is only passed on to Google Ads for now.
claude-opus-5.5 | 𝕏
When the app never sets `adConsent` (at configure or at runtime), the SDK now uses the consent an IAB TCF v2 banner stores in UserDefaults when GDPR applies (`IABTCF_gdprApplies` == 1): ad user data is granted when purposes 1 and 7 are agreed, personalization when purposes 3 and 4 are. Otherwise consent defaults to granted. A new `adConsentSource` device attribute reports `developer`, `tcf` or `default`, and `config_attributes` reports whether the app set the option. The ATT rule now only applies to banner or default consent: an explicit developer setting is always trusted. `.none` still denies both. A banner change, seen through `UserDefaults.didChangeNotification`, republishes device attributes through the serialized ad consent queue when the reported values or source change; the change check now compares both values and the source. The defaults store and notification center are injectable for tests. Docs now say consent is passed on to Google Ads and Meta. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Maple review🔴 Confidence 4/10 · risky as written Adds automatic TCF banner consent detection, source reporting, and developer overrides. A denial during the first device-attributes upload can leave granted consent on the server, so this needs attention before merging.
Before merge
Findings🟠 Warning · F3 ·
|
| Change | Kind | Observable | Evidence |
|---|---|---|---|
| TCFConsentObserver.bannerMayHaveChanged | background | no | Changes produce existing device_attributes events through track; the observer adds no span and follows the SDK's existing notification-handler convention. |
d34d273 · Updated on every push. Reply "won't fix" to dismiss a finding, or mention @maple-review-bot to ask about one.
There was a problem hiding this comment.
ℹ️ No functional issues. One doc comment no longer matches what the code does (inline).
When the app hasn't set adConsent, the SDK now uses what a TCF banner stored. Consent the app sets itself takes priority over both the banner and the ATT rule.
Reviewed changes
I reviewed the one commit since the last review (d34d273). It reads ad consent from IAB TCF banners and reports where the values came from.
- Records whether the app set consent:
SuperwallOptions.adConsentnow has adidSetthat turns onisAdConsentSet, even for a defaultAdConsent().Superwall.adConsentsets the option, so it turns the flag on too. The flag is also sent inconfig_attributes. - Adds a TCF fallback:
TCFConsent.consent(from:)readsIABTCF_PurposeConsentsonly whenIABTCF_gdprAppliesis 1. Purposes 1 and 7 map toadUserData, and 3 and 4 toadPersonalization, the same as Google's Consent Mode mapping. A missing purpose counts as not agreed. - Sets the order of sources:
DeviceHelper.reportedAdConsentuses the app's value first, then the banner's, then granted. It returns aReportedAdConsentthat includes the newadConsentSourcedevice attribute. The ATT rule is skipped when the app set consent itself. The.nonerule still applies to every source. - Compares all three values before re-sending:
adConsentNeedsRepublish()(renamed fromadPersonalizationConsentNeedsRepublish()) now checksadUserData,adPersonalizationandsourceagainst the last device attributes that were queued. A change of source alone also triggers a re-send. - Watches the banner:
TCFConsentObserver, owned byDependencyContainer, filtersUserDefaults.didChangeNotificationdown to real changes in the banner's answer. Each change goes throughrepublishDeviceAttributesIfAdConsentChanged(), so it's still serialized behindadConsentassignments. - Docs and tests: the docs now say Google Ads and Meta, and describe the fallback. New tests cover the purpose mapping, short strings, missing GDPR flags, the order of sources, and re-sends when the banner changes. They use private
UserDefaultssuites.
claude-opus-5.5 | 𝕏
A send reads its ad consent before awaiting the rest of the device attributes, so a banner (or ATT) answer that arrived in between was lost: before the first send there was nothing to compare against, and the in-flight send then went out with the stale value. Every recorded send now queues the serialized republish-if-changed, which compares the current consent and source with what was just sent and does nothing when they match. Also corrects the doc comment on `republishDeviceAttributesIfAdConsentChanged()`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Maple review🟢 Confidence 8/10 · likely safe to merge The latest update reconciles consent after every queued device-attributes event, catching banner or ATT changes during attribute construction. The changed paths are safe to merge, with regression coverage for changes during the first upload and unchanged consent.
What was checked
Observability coverage: 0 of 1 changes observable
|
There was a problem hiding this comment.
✅ No new issues found. A consent change that lands while device attributes are being built is no longer lost.
Reviewed changes
I reviewed the one commit since the last review (de211ff).
- Reconciled after every send: once
track()records a queuedDeviceAttributesevent, it callsreconcileAdConsentAfterPublish(). That queues a republish-if-changed job onAdConsentUpdateQueueand doesn't wait for it. This fixes the case where a banner or ATT answer arrives while the first build is in progress: the observer's check returnsfalsebecause nothing has been recorded yet, and the build then sends the old snapshot. It also fixes sends made outside the queue (session start,setPlatformWrapper) that record an older snapshot after a newer one. The follow-up does nothing when the values match, so a republish can't trigger another one forever. It runs after any job already running, including anadConsentassignment's job, and it's skipped if a later assignment supersedes it. - Shared the enqueue logic:
republishDeviceAttributes(onlyIfAdConsentChanged:)and the reconcile both use the newenqueueDeviceAttributesRepublish(onlyIfAdConsentChanged:completion:). The generation check and the changed-or-not check work as before. - Applied the doc fix: the doc on
republishDeviceAttributesIfAdConsentChanged()now mentions the full consent, its source, and the banner trigger. - Added a test hook and test:
DeviceHelper.afterAdConsentSnapshotruns ingetTemplateDevice()right after consent is read.bannerChangeDuringTheFirstUpload_isSentOnceItFinishesuses it to pause the first build and change the banner, then checks for exactly onetcf/denied resend, and for no resend when nothing changes. Earlier tests now drain the queue after their first send, so the reconcile job finishes before they change anything.
claude-opus-5.5 | 𝕏
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Maple review🟢 Confidence 10/10 · safe to merge Since the previous review, this update only shortens comments describing consent reconciliation and test helpers. It changes no runtime behavior or test assertions and is safe to merge. What was checked
|

What
Adds
SuperwallOptions.adConsentandSuperwall.shared.adConsentso apps can report the user's ad measurement consent. Superwall forwards it with the conversions it uploads to Google Ads and Meta (superwall/paywall-next#4348).AdConsent(adUserData:adPersonalization:), each anAdConsentStatus(.granted/.denied), both.grantedby default. Immutable: change it by assigning a newAdConsent, which re-sends config and device attributes straight away (serialized; the last assignment wins).adConsentif they ever set it; otherwise the consent an IAB TCF consent banner stored (IABTCF_PurposeConsents, whenIABTCF_gdprAppliesis 1: purposes 1 + 7 →adUserData, 3 + 4 →adPersonalization); otherwise granted. Banner changes are observed and re-sent.adUserDataConsent,adPersonalizationConsent("granted"/"denied") andadConsentSource(developer/tcf/default).eventTrackingBehavior == .nonereports both as denied. When App Tracking Transparency is denied or restricted,adPersonalizationis reported as denied unless the developer setadConsentthemselves. ATT is read without prompting..none, and when the banner changes, so the server never keeps a stale value.Design and decisions: SDK Ad Consent API Proposal (internal doc). Android counterpart: superwall/Superwall-Android#389.
Testing
AdConsentTests+DeviceHelperTestson an iPhone 17 Pro iOS 27.0 simulator: 42 tests passed. Covers defaults, wire values, runtime change,.none, and each ATT status.Checklist
CHANGELOG.mdfor any breaking changes, enhancements, or bug fixes.swiftlintin the main directory and fixed any issues. (changed files)docs-required; docs PR to follow)🤖 Generated with Claude Code

Confidence Score: 5/5
The PR appears safe to merge; no new actionable issue was found.Summary
Adds
adConsentoptions and device attributes for Google Ads conversion uploads. Reported values account for disabled event tracking and ATT restrictions without changing the app's stored choices..superwallOnly..superwallOnlyintentionally re-sends attributes because it also clears queued events.Diagram
%%{init: {'theme': 'neutral'}}%% flowchart TD A[App changes tracking behavior] --> B[Queue applies behavior] B --> C{Enabled behavior needs fresh attributes?} C -->|Yes| D[Serial consent queue] D --> E[Read options and ATT status] E --> F[Track device attributes] F --> G[Queue accepts event] G --> H[Remember reported consent] C -->|No| I[No replacement upload]Reviews (3) · Last reviewed commit: "Say ad consent is only passed on to Goog..." · Reviewed by Greptile