Skip to content

Add ad consent option for Google Ads conversion uploads - #544

Merged
yusuftor merged 15 commits into
developfrom
yusuf/ad-consent
Oct 9, 2026
Merged

yusuftor merged 15 commits into
developfrom
yusuf/ad-consent

Conversation

@yusuftor

@yusuftor yusuftor commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator

What

Adds SuperwallOptions.adConsent and Superwall.shared.adConsent so 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 an AdConsentStatus (.granted / .denied), both .granted by default. Immutable: change it by assigning a new AdConsent, which re-sends config and device attributes straight away (serialized; the last assignment wins).
  • Which value is reported: the developer's adConsent if they ever set it; otherwise the consent an IAB TCF consent banner stored (IABTCF_PurposeConsents, when IABTCF_gdprApplies is 1: purposes 1 + 7 → adUserData, 3 + 4 → adPersonalization); otherwise granted. Banner changes are observed and re-sent.
  • Reported as the device attributes adUserDataConsent, adPersonalizationConsent ("granted" / "denied") and adConsentSource (developer / tcf / default).
  • eventTrackingBehavior == .none reports both as denied. When App Tracking Transparency is denied or restricted, adPersonalization is reported as denied unless the developer set adConsent themselves. ATT is read without prompting.
  • Device attributes are re-sent when ATT changes, after any tracking-behavior change other than to .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 + DeviceHelperTests on an iPhone 17 Pro iOS 27.0 simulator: 42 tests passed. Covers defaults, wire values, runtime change, .none, and each ATT status.

Checklist

  • All unit tests pass. (full suite locally on iPhone 17 Pro / iOS 27.0; CI pending on the latest push)
  • All UI tests pass.
  • Demo project builds and runs on iOS.
  • Demo project builds and runs on Mac Catalyst.
  • Demo project builds and runs on visionOS.
  • I added/updated tests or detailed why my change isn't tested.
  • I added an entry to the CHANGELOG.md for any breaking changes, enhancements, or bug fixes.
  • I have run swiftlint in the main directory and fixed any issues. (changed files)
  • I have updated the SDK documentation as well as the online docs. (flagged docs-required; docs PR to follow)
  • I have reviewed the contributing guide

🤖 Generated with Claude Code

RetriggerConfidence Score: 5/5

The PR appears safe to merge; no new actionable issue was found.

Summary

Adds adConsent options 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.

  • The latest change re-sends device attributes after tracking changes that can discard queued consent.
  • Adds a test that checks the replacement event remains queued after switching to .superwallOnly.
  • The three earlier, unnumbered findings are addressed: consent assignments run in order, setter tests check both events, and tracking changes replace discarded device attributes.
  • yusuftor confirmed that re-setting .superwallOnly intentionally 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]
Loading

Reviews (3) · Last reviewed commit: "Say ad consent is only passed on to Goog..." · Reviewed by Greptile

yusuftor and others added 3 commits October 8, 2026 18:21
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-bot

maple-review-bot Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Maple review

🟡 Confidence 5/10 · needs attention
ATT denial leaves server consent stale; tests cover fixed statuses but not transitions after an upload.
quality 90/100 · 1 warning · tests partial · risk high · 0/1 new units observable

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.

  • Superwall.adConsent queues config and device attribute updates.
  • AdConsent.reported applies tracking opt-out and ATT overrides.

Findings

🟠 Warning · F1 · adPersonalizationConsent stays granted after an ATT denial

correctness · Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift:1032-1035

Config fetched with ATT .notDetermined publishes the default "granted" value. Denying the tracking prompt later in that session does not publish updated device attributes: PaywallMessageHandler.swift:679–699 only sends permission events, and AppSessionManager.swift:115–155 republishes device attributes only for a new session. The server's device_attributes_rep therefore retains granted personalization consent after the denial.

Republish device attributes when ATT changes, including after the SDK's tracking permission request and on activation, with a change gate and a test covering `.notDetermined` → `.denied` after the initial upload.
🤖 Prompt to fix this finding with an AI agent
Findings from an automated review of commit 426667a69b4b96bc5607bb6ae69da57afea13a37. Verify each one against the current code before changing anything, fix only those that still apply, and keep each fix to the lines it names.

---

F1 · Warning · correctness · Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift:1032-1035
`adPersonalizationConsent` stays granted after an ATT denial
Config fetched with ATT `.notDetermined` publishes the default `"granted"` value. Denying the tracking prompt later in that session does not publish updated device attributes: `PaywallMessageHandler.swift:679–699` only sends permission events, and `AppSessionManager.swift:115–155` republishes device attributes only for a new session. The server's `device_attributes_rep` therefore retains granted personalization consent after the denial.
Suggested fix: Republish device attributes when ATT changes, including after the SDK's tracking permission request and on activation, with a change gate and a test covering `.notDetermined` → `.denied` after the initial upload.
What was checked
  • AdConsentTests covers defaults, serialization, opt-out, and each fixed ATT status.
  • DeviceHelper gives local consent values priority over enrichment fields.
  • Runtime updates use the existing track and PlacementsQueue pipeline.
Observability coverage: 0 of 1 changes observable
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.

@yusuftor yusuftor added the docs-required Ships a customer-facing change that needs a superwall/docs update label Oct 8, 2026
@yusuftor

yusuftor commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

📚 Docs required

What changed for customers: apps can now report a user's ad consent with SuperwallOptions.adConsent / Superwall.shared.adConsent (both statuses default to granted). Superwall sends it along with ad-network conversion uploads such as Google Ads. Personalization is reported as denied when ATT is denied or restricted, and both are reported as denied under eventTrackingBehavior = .none.

Why this needs docs: adds a new SuperwallOptions field (adConsent) to the option list the SDK reference enumerates, with a compliance prerequisite (EEA/UK/CH apps must set it from their consent flow). This is one feature across #544 and superwall/Superwall-Android#389; one docs PR should cover both.

Coverage today: absent for adConsent / AdConsent / ConsentStatus / adUserDataConsent (zero hits in superwall/docs) — content/docs/ios/sdk-reference/SuperwallOptions.mdx (https://superwall.com/docs/ios/sdk-reference/SuperwallOptions), content/docs/android/sdk-reference/SuperwallOptions.mdx (https://superwall.com/docs/android/sdk-reference/SuperwallOptions), content/docs/integrations/meta-ads.mdx (https://superwall.com/docs/integrations/meta-ads)

Without a docs page this change also gets no changelog entry: superwall/docs publishes a daily GitHub Release that superwall.com renders at /changelog, and every entry must link to a live docs page. Undocumented means invisible to customers.

Prompt for the docs agent — run in superwall/docs
Document a feature that shipped across two SDK PRs, treated as one change:
superwall/Superwall-iOS#544 (Add ad consent option for Google Ads conversion uploads) and
superwall/Superwall-Android#389 (feat: add android install attribution matching).

What shipped, in customer terms:
1. Ad consent option (iOS and Android). New `SuperwallOptions.adConsent`, also settable at runtime via
   `Superwall.shared.adConsent` (iOS) / `Superwall.instance.adConsent` (Android). Type is
   `AdConsent(adUserData:adPersonalization:)`, each a `ConsentStatus` (iOS `.granted` / `.denied`,
   Android `GRANTED` / `DENIED`). Both default to granted. AdConsent is immutable; change it by assigning a
   new value, which re-sends device attributes immediately. Superwall forwards it with the conversions it
   uploads to ad networks such as Google Ads. Reported as the device attributes `adUserDataConsent` and
   `adPersonalizationConsent` ("granted" / "denied"). Rules: `eventTrackingBehavior` = none reports both as
   denied; on iOS, when App Tracking Transparency is denied or restricted, personalization is reported as
   denied (notDetermined follows the option; the SDK never prompts for ATT).
   Apps with users in the EEA, UK or Switzerland must set it from their consent flow.
2. Android install attribution matching. Android now matches an install to the ad click that led to it,
   like iOS already does: once per install, only within 7 days of install, only when the dashboard has turned
   matching on for the app, never blocking startup, using the Play install referrer click id when present.
   It tracks an `attribution_match` SuperwallEvent (AttributionMatchInfo: provider, matched, source,
   confidence high/medium/low, matchScore, reason) and merges `acquisition_*` attributes into user
   attributes, usable in chart breakdowns/filters and audiences, carried over after identify/reset.
   Skipped while eventTrackingBehavior is NONE; runs if tracking is turned back on.
   Also a fix: web checkout codes passed through the Play install referrer are now redeemed (once, on first
   launch after install).

Setup or prerequisites a customer must complete:
- EEA/UK/CH apps: set `adConsent` from their consent management flow at configure time and update it when
  the user changes consent.
- Android attribution: update to the Android SDK version that ships #389 (check its CHANGELOG/release tag)
  and ship a new build; new installs only.

Where it belongs:
- content/docs/ios/sdk-reference/SuperwallOptions.mdx — add `adConsent` to the signature (~line 31) and
  TypeTable (near `eventTrackingBehavior`, ~line 78); explain defaults, the ATT and `.none` rules.
- content/docs/android/sdk-reference/SuperwallOptions.mdx — same for Kotlin/Java signatures (~lines 23, 42)
  and TypeTable (~line 74).
- content/shared/configuring/using-superwalloptions.mdx — short "Ad consent" section with the EEA/UK/CH
  requirement and an example of setting it from a consent flow (iOS + Android tabs).
- content/docs/ios/sdk-reference/Superwall.mdx and content/docs/android/sdk-reference/Superwall.mdx — add
  the runtime `adConsent` property if those pages enumerate runtime properties.
- content/docs/integrations/meta-ads.mdx line 25 — "Meta Ads attribution is iOS only for now" is now stale;
  add the Android SDK minimum version and the Play install referrer note. Adjust the Warning at ~line 30
  (mentions only SDK 4.16.0).
- content/docs/integrations/mmp.mdx — mention Android support and the ad consent option (Google Ads is listed
  as "coming soon" at line 20; add consent guidance where Google Ads is or will be documented).
- content/docs/android/sdk-reference/SuperwallEvent.mdx — add `AttributionMatch` / `attribution_match`
  (iOS SuperwallEvent.mdx also lacks it; add there too). tracking-analytics.mdx already lists the event.
- content/docs/ios/changelog.mdx and content/docs/android/changelog.mdx — entries once the SDK versions ship.

Source of truth — read these before writing:
- gh pr diff 544 --repo superwall/Superwall-iOS --patch
- gh pr diff 389 --repo superwall/Superwall-Android --patch
- iOS: Sources/SuperwallKit/Config/Options/AdConsent.swift, Sources/SuperwallKit/Config/Options/SuperwallOptions.swift,
  Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift, CHANGELOG.md
- Android: superwall/src/main/java/com/superwall/sdk/config/options/AdConsent.kt,
  .../analytics/superwall/AttributionMatchInfo.kt, .../analytics/attribution/MMPAttributionManager.kt, CHANGELOG.md

Constraints:
- Match the voice and structure of neighbouring pages under content/docs/**.
- Use the Fumadocs TypeTable for parameters and types; never <ParamTable>.
- If you add a page, add it to the folder's meta.json.
- Run `bun run build:cf` and `bun test` before opening the PR.
- Open a PR against superwall/docs referencing superwall/Superwall-iOS#544 and superwall/Superwall-Android#389. Do not deploy.

Flagged by the docs-required skill. If this is wrong, remove the label and say why in a reply so the skill's calibration set can be corrected.

@maple-review-bot maple-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 inline note from Maple's review. The score and summary are in the review comment above.

Comment thread Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift Outdated

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ 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/ConsentStatus types: an immutable NSObject with adUserData/adPersonalization, both defaulting to .granted, bridged to Objective‑C as SWKAdConsent/SWKConsentStatus.
  • Option and runtime setter: SuperwallOptions.adConsent and Superwall.shared.adConsent. The setter re-sends config attributes, then a DeviceAttributes event, the same way setInterfaceStyle does.
  • Reported device attributes: getTemplateDevice() now emits adUserDataConsent/adPersonalizationConsent. .none tracking denies both, and an ATT status of denied or restricted denies personalization. The raw values still go into config_attributes.
  • ATT injection: DeviceHelper now also requires OptionsFactory and takes an attStatusProvider, so tests can fix the ATT status.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using claude-opus-5.5 | 𝕏

Comment thread Sources/SuperwallKit/Config/Options/AdConsent.swift Outdated
Comment thread Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift Outdated
Comment thread Sources/SuperwallKit/Superwall.swift Outdated
Comment thread Tests/SuperwallKitTests/Config/AdConsentTests.swift
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-bot

maple-review-bot Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Maple review

🟡 Confidence 6/10 · needs attention
Sequential ATT refreshes and activation deduplication are tested; the paywall permission hook has no direct regression test.
quality 100/100 · no findings · tests partial · risk high · 0/1 new units observable

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.

  • track records the personalization consent in tracked device attributes.
  • claimAdPersonalizationConsentRepublish suppresses unchanged refreshes.
What was checked
  • DeviceAttributes is not filtered by the verbose-placement flag in TrackingLogic.
  • requestTrackingPermission awaits authorization before the new republish hook runs.
  • AdConsentTests checks changed, unchanged, pre-upload, and app-activation refresh behavior.
Observability coverage: 0 of 1 changes observable
Change Kind Observable Evidence
ATT-triggered device-attribute refresh background no Uses the existing DeviceAttributes tracking pipeline and structured Logger; no OpenTelemetry span helpers were found in SuperwallKit.

9747972 · Updated on every push. Reply "won't fix" to dismiss a finding, or mention @maple-review-bot to ask about one.

- 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>
@maple-review-bot

maple-review-bot Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Warning

The review of 53a0e23 could not finish. It stopped before it finished, so its partial findings are not posted here. Comment @maple-review-bot review to try again.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ 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: DeviceHelper now stores the adPersonalizationConsent from each DeviceAttributes event that passes through track(). 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 .tracking permission 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.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using claude-opus-5.5 | 𝕏

Comment thread Sources/SuperwallKit/Analytics/Internal Tracking/Tracking.swift Outdated
`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-bot

maple-review-bot Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Maple review

🟢 Confidence 10/10 · safe to merge
The production change only injects the existing callback, and the new parameterized test exercises both permission branches.
quality 100/100 · no findings · tests covered · risk low

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
  • Compared all three files against previously reviewed commit 53a0e23.
  • Default callback invokes the same consent republisher as before.
  • New test checks device-event counts and consent values after the permission response.

a55a7ac · Updated on every push. Reply "won't fix" to dismiss a finding, or mention @maple-review-bot to ask about one.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ The rename and the serialized update queue look correct. There's one small doc nit inline. The .none edge case from the previous review (on Tracking.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 ConsentStatus to AdConsentStatus: this avoids clashing with FirebaseAnalytics.ConsentStatus. The Objective‑C name stays SWKConsentStatus.
  • Added AdConsentUpdateQueue: a single consumer that runs jobs from an AsyncStream one at a time, plus a lock-guarded generation counter. Each adConsent assignment 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_attributes and device_attributes, and that the last of two rapid assignments wins. The custom recorder was replaced with MockSuperwallDelegate, which now keeps the full SuperwallEventInfo too.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using claude-opus-5.5 | 𝕏

Comment thread Sources/SuperwallKit/Superwall.swift Outdated

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No new issues in this commit. Two earlier threads are still open and unchanged: the misplaced doc comment on republishDeviceAttributesIfAdConsentChanged() (Superwall.swift) and the .none edge 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 new republishIfAdConsentChanged closure parameter defaults to Superwall.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 .tracking request re-sends adPersonalizationConsent: denied before permission_result reaches the web view, and that a .notification request sends nothing.
  • Extended FakePermissionHandler: an onRequest callback runs while a request is in flight, so the test can change what the user answered.

Pullfrog  | Fix it ➔ | View workflow run | Using 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-bot

maple-review-bot Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Maple review

🟡 Confidence 6/10 · needs attention
The resume path compares only personalization, leaving user-data consent withdrawals unreported; the added regression test changes ATT only.
quality 90/100 · 1 warning · tests partial · risk high · 1/1 new units observable

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.

  • track records published consent only after PlacementsQueue.enqueue accepts the event.
  • eventTrackingBehavior triggers serialized consent catch-up when leaving .none.

Findings

🟠 Warning · F2 · eventTrackingBehavior misses pending user-data consent changes

correctness · Sources/SuperwallKit/Superwall.swift:90-91

After publishing both consents as granted, switch to .none, set only adUserData to .denied, let that update finish, then switch back to .all. The update is dropped while opted out, and adPersonalizationConsentNeedsRepublish() compares only personalization, so this catch-up sends no device attributes; the server's device_attributes_rep retains granted user-data consent when conversion tracking resumes.

Republish current device attributes when leaving `.none`, or track and compare both consent fields so a user-data-only change is sent. Add an opt-out/resume test with unchanged personalization and denied user-data consent.
🤖 Prompt to fix this finding with an AI agent
Findings from an automated review of commit 8ed70bf6a1f5d75f9c20793810f773f87ac35a85. Verify each one against the current code before changing anything, fix only those that still apply, and keep each fix to the lines it names.

---

F2 · Warning · correctness · Sources/SuperwallKit/Superwall.swift:90-91
`eventTrackingBehavior` misses pending user-data consent changes
After publishing both consents as granted, switch to `.none`, set only `adUserData` to `.denied`, let that update finish, then switch back to `.all`. The update is dropped while opted out, and `adPersonalizationConsentNeedsRepublish()` compares only personalization, so this catch-up sends no device attributes; the server's `device_attributes_rep` retains granted user-data consent when conversion tracking resumes.
Suggested fix: Republish current device attributes when leaving `.none`, or track and compare both consent fields so a user-data-only change is sent. Add an opt-out/resume test with unchanged personalization and denied user-data consent.
What was checked
  • PlacementsQueue rejects events under .none and allows device attributes under .superwallOnly.
  • Consent bookkeeping follows queue acceptance; verbose-event filtering does not exclude device attributes.
  • The new ATT opt-out/resume test exercises the republish path through Superwall.
Observability coverage: 1 of 1 changes observable
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.

@maple-review-bot maple-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 inline note from Maple's review. The score and summary are in the review comment above.

Comment thread Sources/SuperwallKit/Superwall.swift Outdated

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ The .none edge 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.enqueue now returns whether it accepted the event. track() records the published adPersonalizationConsent only if the queue accepted it and eventTrackingBehavior isn't .none.
  • Made the republish check read-only: claimAdPersonalizationConsentRepublish() became adPersonalizationConsentNeedsRepublish(). It no longer marks the value as sent, and returns false under .none. AdConsentUpdateQueue now prevents double sends.
  • Catch-up on opt-in: leaving .none through the eventTrackingBehavior setter runs republishDeviceAttributesIfAdConsentChanged() once the queue accepts events again.
  • Doc comment fix: @discardableResult now sits below the doc comment.
  • Tests: added attChangeWhileOptedOut_republishesOnceTrackingResumes, which covers the exact sequence from the earlier thread.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using claude-opus-5.5 | 𝕏

Comment thread Sources/SuperwallKit/Superwall.swift Outdated
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-bot

maple-review-bot Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Maple review

🟢 Confidence 9/10 · safe to merge
The revised catch-up path has a dedicated opt-out consent regression test; activation extraction preserves the existing call order.
quality 100/100 · no findings · tests covered · risk high · 1/1 new units observable

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
  • Opt-in updates PlacementsQueue before scheduling the unconditional device-attributes refresh.
  • Consent refreshes retain AdConsentUpdateQueue serialization and generation checks.
  • didBecomeActive preserves app-open, session-refresh, and consent-republish ordering.
Observability coverage: 1 of 1 changes observable
Change Kind Observable Evidence
Device-attributes catch-up when tracking resumes background work yes Uses the existing track(DeviceAttributes) pipeline, including delegate notification, Logger output, and PlacementsQueue.

5757f03 · Updated on every push. Reply "won't fix" to dismiss a finding, or mention @maple-review-bot to ask about one.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found. The opt-in gap from the last review is closed: leaving .none now always re-sends device attributes.

Reviewed changes

I reviewed the one commit since the last review (5757f03).

  • Unconditional re-send on opt-in: the eventTrackingBehavior setter now calls the new republishDeviceAttributes(onlyIfAdConsentChanged: false) when it leaves .none. An adConsent assignment made while opted out (e.g. adUserData: .denied) now reaches device_attributes. The job still runs on AdConsentUpdateQueue and captures currentGeneration after 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 with true, so the ATT activation and permission paths behave the same as before.
  • Extracted AppSessionManager.didBecomeActive(): the notification handler now just awaits this method in a Task. Event order is unchanged: AppOpen, then the session check, then the republish.
  • Tests: added adConsentSetWhileOptedOut_isSentOnceTrackingResumes. The activation test now awaits didBecomeActive() directly rather than posting the app-wide notification, so it no longer needs the sleeps.

Pullfrog  | View workflow run | Using 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-bot

maple-review-bot Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Maple review

🟢 Confidence 9/10 · safe to merge
The incremental change isolates test notifications without changing production lifecycle handling or consent reporting.
quality 100/100 · no findings · tests covered · risk low

The latest revision injects NotificationCenter into AppSessionManager and isolates the activation consent test from app-wide notifications. Production still defaults to the shared notification center; this revision is safe to merge.

What was checked
  • Compared both changed files against previously reviewed commit 5757f03.
  • All three lifecycle observers use the injected center; production defaults to .default.
  • Activation test still awaits the handler and asserts exactly one ATT consent update.

5c1f9aa · Updated on every push. Reply "won't fix" to dismiss a finding, or mention @maple-review-bot to ask about one.

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-bot

maple-review-bot Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Maple review

🟢 Confidence 10/10 · safe to merge
The incremental changes preserve dependency identity and use the existing serial queue to finish test-started consent work.
quality 100/100 · no findings · tests covered · risk low

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.

  • makeDeviceHelper overrides ATT on the existing helper instead of replacing it.
  • waitForPendingAdConsentUpdates provides a serial-queue barrier for test cleanup.
What was checked
  • Confirmed IdentityManager, ConfigManager, and AttributionFetcher retain unowned references to the original device helper.
  • Verified the cleanup barrier runs after earlier jobs in AdConsentUpdateQueue.
  • Compared against 5c1f9aa; changes are limited to ATT test injection and lifetime cleanup.

cd4f064 · Updated on every push. Reply "won't fix" to dismiss a finding, or mention @maple-review-bot to ask about one.

Comment thread Sources/SuperwallKit/Analytics/Internal Tracking/Tracking.swift
yusuftor and others added 2 commits October 9, 2026 11:20
`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-bot

maple-review-bot Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Maple review

🟢 Confidence 8/10 · likely safe to merge
The queue-clearing regression has a test asserting denied consent is re-enqueued after the tracking-behavior transition.
quality 100/100 · no findings · tests covered · risk high · 1/1 new units observable

The latest changes re-send device attributes after tracking-behavior changes, preserving queued consent updates when switching to .superwallOnly, and clarify Google Ads-only support. No new defects found in the changes since the previous review; safe to merge once required checks pass.

  • eventTrackingBehavior republishes device attributes after the queue applies a changed behavior.
  • switchingToSuperwallOnly_resendsDiscardedDeviceAttributes verifies denied consent remains queued.
  • adConsent documentation limits current downstream support to Google Ads.

Before merge

  • Manual · Publish online documentation for SuperwallOptions.adConsent and Superwall.adConsent.
What was checked
  • Confirmed .superwallOnly accepts the replacement DeviceAttributes event after clearing the queue.
  • Read the serialized republish path and regression assertions for both consent value and queue contents.
  • Compared head against cd4f064 to isolate changes since the previous review.
Observability coverage: 1 of 1 changes observable
Change Kind Observable Evidence
Tracking-behavior-triggered device-attributes republish background work yes Uses existing track(DeviceAttributes) flow, which reports delegate events and structured Logger.debug output in Tracking.swift:25–39.

1750d2d · Updated on every push. Reply "won't fix" to dismiss a finding, or mention @maple-review-bot to ask about one.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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 eventTrackingBehavior setter now re-sends unconditionally on every change to a value other than .none, unless it's .all → .all. That covers the case where PlacementsQueue.setTrackingBehavior throws away a queued device_attributes event that was already recorded as sent (e.g. .all → .superwallOnly). .superwallOnly → .all, or setting .superwallOnly twice, sends one extra device_attributes event, which does no harm.
  • Added a test seam and test: PlacementsQueue.queuedEventNames lets the new switchingToSuperwallOnly_resendsDiscardedDeviceAttributes test 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.adConsent and the CHANGELOG now say that consent is only passed on to Google Ads for now.

Pullfrog  | View workflow run | Using 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-bot

maple-review-bot Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Maple review

🔴 Confidence 4/10 · risky as written
Banner updates can be discarded during the first attributes upload; the observer test only changes consent after that upload finishes.
quality 90/100 · 1 warning · tests partial · risk high · 0/1 new units observable

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.

  • TCFConsent derives advertising consent from stored purpose bits.
  • TCFConsentObserver republishes attributes after banner changes.
  • reportedAdConsent prioritizes developer settings and reports adConsentSource.

Before merge

  • Manual · Publish online documentation for TCF detection, developer overrides, and the changed ATT precedence.

Findings

🟠 Warning · F3 · TCFConsentObserver drops denials during the first attributes upload

correctness · Sources/SuperwallKit/Config/Options/TCFConsent.swift:88

The first config upload snapshots consent before awaiting StoreKit work (DeviceHelper.swift:1101–1149). A banner denial during that wait invokes this callback, but adConsentNeedsRepublish() returns false because no attributes have been published yet (DeviceHelper.swift:1036–1037). The pending upload then sends the old granted snapshot, and the observer has already consumed the denial, leaving the server with granted consent until another publication or activation.

Reconcile current consent after device-attributes publications, including the initial upload, and add a test that changes banner consent while the first snapshot is suspended.
🤖 Prompt to fix this finding with an AI agent
Findings from an automated review of commit d34d273d4e597be77b924d3845d3798d44e650d3. Verify each one against the current code before changing anything, fix only those that still apply, and keep each fix to the lines it names.

---

F3 · Warning · correctness · Sources/SuperwallKit/Config/Options/TCFConsent.swift:88
`TCFConsentObserver` drops denials during the first attributes upload
The first config upload snapshots consent before awaiting StoreKit work (`DeviceHelper.swift:1101–1149`). A banner denial during that wait invokes this callback, but `adConsentNeedsRepublish()` returns false because no attributes have been published yet (`DeviceHelper.swift:1036–1037`). The pending upload then sends the old granted snapshot, and the observer has already consumed the denial, leaving the server with granted consent until another publication or activation.
Suggested fix: Reconcile current consent after device-attributes publications, including the initial upload, and add a test that changes banner consent while the first snapshot is suspended.
What was checked
  • Developer overrides and .none precedence are exercised in AdConsentTests.
  • Banner updates reuse track and its queued-publication bookkeeping.
  • Config upload snapshots consent before asynchronous StoreKit reads (DeviceHelper.swift:1101–1149).
Observability coverage: 0 of 1 changes observable
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.

@maple-review-bot maple-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 inline note from Maple's review. The score and summary are in the review comment above.

Comment thread Sources/SuperwallKit/Config/Options/TCFConsent.swift

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ 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.adConsent now has a didSet that turns on isAdConsentSet, even for a default AdConsent(). Superwall.adConsent sets the option, so it turns the flag on too. The flag is also sent in config_attributes.
  • Adds a TCF fallback: TCFConsent.consent(from:) reads IABTCF_PurposeConsents only when IABTCF_gdprApplies is 1. Purposes 1 and 7 map to adUserData, and 3 and 4 to adPersonalization, the same as Google's Consent Mode mapping. A missing purpose counts as not agreed.
  • Sets the order of sources: DeviceHelper.reportedAdConsent uses the app's value first, then the banner's, then granted. It returns a ReportedAdConsent that includes the new adConsentSource device attribute. The ATT rule is skipped when the app set consent itself. The .none rule still applies to every source.
  • Compares all three values before re-sending: adConsentNeedsRepublish() (renamed from adPersonalizationConsentNeedsRepublish()) now checks adUserData, adPersonalization and source against the last device attributes that were queued. A change of source alone also triggers a re-send.
  • Watches the banner: TCFConsentObserver, owned by DependencyContainer, filters UserDefaults.didChangeNotification down to real changes in the banner's answer. Each change goes through republishDeviceAttributesIfAdConsentChanged(), so it's still serialized behind adConsent assignments.
  • 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 UserDefaults suites.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using claude-opus-5.5 | 𝕏

Comment thread Sources/SuperwallKit/Superwall.swift Outdated
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-bot

maple-review-bot Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Maple review

🟢 Confidence 8/10 · likely safe to merge
The incremental reconciliation reuses the existing serialized update path, with regression tests covering first-upload changes and the no-op case.
quality 100/100 · no findings · tests covered · risk high · 0/1 new units observable

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.

  • reconcileAdConsentAfterPublish schedules consent checks after accepted device-attributes events.
  • SnapshotGate exercises consent changes during a suspended first upload.
What was checked
  • Reconciliation uses the existing serial queue without awaiting itself from track.
  • Equal reported consent stops follow-up uploads in adConsentNeedsRepublish.
  • Regression tests assert both the changed-consent upload and the unchanged-consent no-op.
Observability coverage: 0 of 1 changes observable
Change Kind Observable Evidence
Post-publish consent reconciliation background no Uses existing device_attributes events and Logger through track; no span instrumentation found in SuperwallKit. This follows the SDK's existing tracking convention.

de211ff · Updated on every push. Reply "won't fix" to dismiss a finding, or mention @maple-review-bot to ask about one.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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 queued DeviceAttributes event, it calls reconcileAdConsentAfterPublish(). That queues a republish-if-changed job on AdConsentUpdateQueue and 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 returns false because 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 an adConsent assignment's job, and it's skipped if a later assignment supersedes it.
  • Shared the enqueue logic: republishDeviceAttributes(onlyIfAdConsentChanged:) and the reconcile both use the new enqueueDeviceAttributesRepublish(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.afterAdConsentSnapshot runs in getTemplateDevice() right after consent is read. bannerChangeDuringTheFirstUpload_isSentOnceItFinishes uses it to pause the first build and change the banner, then checks for exactly one tcf/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.

Pullfrog  | View workflow run | Using claude-opus-5.5 | 𝕏

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@maple-review-bot

maple-review-bot Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Maple review

🟢 Confidence 10/10 · safe to merge
The incremental diff changes only comments; consent logic and test assertions are unchanged.
quality 100/100 · no findings · tests not needed · risk low

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
  • Compared de211ff with the head: all three changed files contain comment-only edits.

1d7552f · Updated on every push. Reply "won't fix" to dismiss a finding, or mention @maple-review-bot to ask about one.

@yusuftor
yusuftor merged commit b8d5d98 into develop Oct 9, 2026
6 checks passed
@yusuftor
yusuftor deleted the yusuf/ad-consent branch October 9, 2026 13:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs-required Ships a customer-facing change that needs a superwall/docs update

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant