Ask for consent before showing an ad, serve limited ads without it - #136
Open
andiwand wants to merge 5 commits into
Open
Ask for consent before showing an ad, serve limited ads without it#136andiwand wants to merge 5 commits into
andiwand wants to merge 5 commits into
Conversation
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) <[email protected]> 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) <[email protected]> Claude-Session: https://claude.ai/code/session_018Ens9c7zX2bDxXH8tUL4mG
andiwand
force-pushed
the
add-consent-form-before-ads
branch
from
August 6, 2026 19:00
3f903ef to
b632bc4
Compare
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) <[email protected]> Claude-Session: https://claude.ai/code/session_018Ens9c7zX2bDxXH8tUL4mG
andiwand
marked this pull request as ready for review
August 6, 2026 19:50
There was a problem hiding this comment.
💡 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".
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
ConsentManager, shaped like the AndroidAdManagerflow.viewDidAppear; the form is modal, soviewWillAppearis too early.canRequestAdsand nothing else, never whether the calls came back clean — cached consent must survive an offline update.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 toUserDefaults: 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 —privacyOptionsRequirementStatusis stale until an update completes in-session, so the button would otherwise vanish on the second launch.Before merging
Testing
Both schemes build (
BUILD SUCCEEDED),scripts/format.sh --checkpasses. Not yet run on a device — no message is published, so the form has never appeared. Worth checking withConsentDebugSettings/DEBUG_GEOGRAPHY_EEA: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