Skip to content

Ask for consent before showing an ad, serve limited ads without it - #136

Open
andiwand wants to merge 5 commits into
mainfrom
add-consent-form-before-ads
Open

Ask for consent before showing an ad, serve limited ads without it#136
andiwand wants to merge 5 commits into
mainfrom
add-consent-form-before-ads

Conversation

@andiwand

@andiwand andiwand commented Aug 5, 2026

Copy link
Copy Markdown
Member

Draft — needs the AdMob messages configured before it does anything. See Before merging.

The Lite app serves ads to EEA/UK/CH users with no CMP. ATT is not a substitute: it is Apple's question about the IDFA, and it does not satisfy Google's EU user consent policy, which has required a TCF-integrated CMP since Jan 2024. Android has had this since Dec 2023.

What it does

  • Links GoogleUserMessagingPlatform and adds ConsentManager, shaped like the Android AdManager flow.
  • Consent form first, ATT after — and only for users who will see an ad.
  • Ad setup moves to viewDidAppear; the form is modal, so viewWillAppear is too early.
  • Whether an ad may load is canRequestAds and nothing else, never whether the calls came back clean — cached consent must survive an offline update.
  • No answer on file, no ad: a form that failed to present, or a first launch in a required region that came up offline, leaves nothing to serve against and the banner stays away.

On refusal the banner still loads. "Do not consent" emits a TC string whose special purposes let Google select limited ads server-side: no identifiers, no personalisation. Serving nothing there is stricter than required and gives up the fill. ATT is skipped on that path — a limited ad uses no identifier for it to govern.

Note that refusal is not canRequestAds == false. That property reports only that an answer was gathered, and "do not consent" is an answer (the SDK team is explicit: UMP "deals with consent management only, and does not provide ad service status"). What the answer allows is read from the TCF signals UMP writes to UserDefaults: purpose 1, storing and reading information on the device, is the flag below which Google can serve neither personalised nor non-personalised ads and falls back to limited ads.

That same flag decides ATT, since it is the same line — ATT is asked of users who allow storage but refuse personalisation: non-personalised ads still use the identifier for frequency capping and cross-app reporting, which is what ATT gates.

Consent is also withdrawable. GDPR Art. 7(3) and TCF both want that. There is no settings screen, so a Privacy item on the document browser offers the UMP form (only where one exists) and a route to the iOS tracking toggle. ConsentManager.refresh() runs a silent update per launch — privacyOptionsRequirementStatus is stale until an update completes in-session, so the button would otherwise vanish on the second launch.

Before merging

  • Publish the GDPR message in AdMob → Privacy & messaging, targeted EEA/UK/CH, for the Lite iOS app and its ad unit. Without it the SDK returns no form.
  • Turn on "Programmatic limited ads" (AdMob → Settings → Account information). The refusal path is near-zero fill without it.
  • Consider a US states (CCPA/CPRA) message.
  • Legal read on serving limited ads without consent.

Testing

Both schemes build (BUILD SUCCEEDED), scripts/format.sh --check passes. Not yet run on a device — no message is published, so the form has never appeared. Worth checking with ConsentDebugSettings / DEBUG_GEOGRAPHY_EEA:

Scenario Expected
Accept all form → ATT → banner
Accept storage, refuse personalisation form → ATT still asked → banner
Refuse everything form → no ATT → banner still loads
Accept, relaunch offline banner loads from cache, no form
First launch offline, EEA no form, no ATT, no banner
Outside EEA no form, ATT, banner — as today
Second launch, EEA Privacy button still there
ODR Full no banner, no form, no button

Expect fill rate to move in both directions once live: down as users decline personalisation, up as refusers get limited ads instead of nothing. Watch the serving-restriction dimension in AdMob.

🤖 Generated with Claude Code

andiwand and others added 2 commits August 6, 2026 20:56
The Lite app serves ads to EEA, UK and Swiss users with no consent
management platform in front of them. ATT is not a substitute: it is
Apple's question about the IDFA, and it neither covers the ePrivacy rule
on storing and reading identifiers on the device nor satisfies Google's
EU user consent policy, which has required a certified CMP integrated
with IAB TCF since January 2024. The Android app has had one since
f6b942b6; this side never got it.

Add Google's UMP SDK, already resolving transitively as a dependency of
GoogleMobileAds and now linked explicitly, and gather consent before the
banner loads. Whether an ad may be loaded is canRequestAds() and nothing
else - never whether the consent calls came back clean - because the SDK
caches the decision, so a form that fails to present or an update that
times out offline still leaves an earlier consent standing, and outside
the regions where a form is required at all there is no decision to fail.

Who is asked is decided by UMP, not here: the messages are geo-targeted
in AdMob under Privacy & messaging and the SDK resolves the region
server-side, so users outside a configured region see no form and get
canRequestAds() == true, as today.

Move the ad setup to viewDidAppear. The form is presented modally and
viewWillAppear is too early for that, since the controller is not in the
window hierarchy yet. ATT now follows the consent form rather than
replacing it, and is only asked of users who will actually see an ad.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PafKp4GDZk7AYPbSNLTiti
Two changes to the consent flow, from reading it against the ad-consent
notes in the headquarters repo.

Refusing consent no longer hides the banner. "Do not consent" still emits
a TC string carrying the special purposes, and Google selects limited ads
from it server-side: no cookies, no identifiers, no local storage. Serving
nothing there was stricter than the rules require and gave up the fill
outright -- roughly a fifth of requests already go unfilled. ATT is still
skipped on that path, because limited ads use no advertising identifier
for it to govern.

ATT is unchanged on the consenting path, including for users who allow
storage but refuse personalisation. A non-personalised ad still uses the
identifier for frequency capping and aggregated reporting across apps,
which is the thing ATT actually gates, so the question is not moot.

Consent was also collectable but not withdrawable, which GDPR Art. 7(3)
and TCF both want. The app has no settings screen, so the entry point goes
on the document browser's own chrome: a Privacy item offering the UMP form
-- only where a form exists at all -- and a route to the iOS tracking
toggle, which is the sole way back to an answer ATT only asks once.

That entry point is why ConsentManager gains refresh(): both
privacyOptionsRequirementStatus and canRequestAds answer from a cache only
filled by an update completing in the current session, so without a silent
update per launch the button would vanish on the second one.

Logging moves from print to CrashManager, as on Android.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Ens9c7zX2bDxXH8tUL4mG
@andiwand
andiwand force-pushed the add-consent-form-before-ads branch from 3f903ef to b632bc4 Compare August 6, 2026 19:00
@andiwand andiwand changed the title Ask for consent before showing an ad in the Lite app Ask for consent before showing an ad, serve limited ads without it Aug 6, 2026
The privacy_* keys exist only in en.lproj. NSLocalizedString resolves the
bundle to the user's locale first and returns the *key* when it is missing
there, so the other 16 localizations would have shown "privacy" and
"privacy_ad_choices" as the button and menu titles.

Passing value: gives each lookup its English text as the fallback. Only the
five new keys need it; cancel is already translated.

Same defect Codex reported on #138.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Ens9c7zX2bDxXH8tUL4mG
@andiwand
andiwand marked this pull request as ready for review August 6, 2026 19:50

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1c5889468d

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread OpenDocumentReader/DocumentViewController.swift
canRequestAds says an answer has been gathered, not that it was yes -
"do not consent" leaves it true, and Google's own SDK team is explicit
that UMP reports consent state and never serving eligibility. So the
false branch was never the refusal path it was written for: it is the
first launch in a required region where the update timed out or the
form failed, with no consent signal to send at all. Requesting a banner
there is the one thing the gate exists to prevent.

The refusal path is still served, from where the answer actually lives.
UMP writes the TCF signals into UserDefaults; purpose 1 covers storing
and reading information on the device, and without it Google can serve
neither personalised nor non-personalised ads and falls back to limited
ads, which carry no identifier. That is also the line ATT cares about,
so the same flag decides whether to ask: refuse storage and there is
nothing for Apple's question to govern; allow storage and refuse
personalisation and the identifier is still in play.
Three passes over the comments this branch added. Nothing was dropped
that the code does not already say: what is left is the part a reader
cannot get from the call itself - that canRequestAds reports an answer
rather than a yes, that purpose 1 is where the answer lives, that the
form is modal and so cannot go in viewWillAppear, and why a refusal is
still served.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant