From 9c9c847a841dd641fd5b6f9dd04846dc06fa115b Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Thu, 6 Aug 2026 00:10:07 +0200 Subject: [PATCH 1/5] Ask for consent before showing an ad in the Lite app 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) Claude-Session: https://claude.ai/code/session_01PafKp4GDZk7AYPbSNLTiti --- OpenDocumentReader.xcodeproj/project.pbxproj | 21 ++++++ OpenDocumentReader/ConsentManager.swift | 72 +++++++++++++++++++ .../DocumentViewController.swift | 32 +++++++-- 3 files changed, 119 insertions(+), 6 deletions(-) create mode 100644 OpenDocumentReader/ConsentManager.swift diff --git a/OpenDocumentReader.xcodeproj/project.pbxproj b/OpenDocumentReader.xcodeproj/project.pbxproj index 132e9c0..1a9b1ca 100644 --- a/OpenDocumentReader.xcodeproj/project.pbxproj +++ b/OpenDocumentReader.xcodeproj/project.pbxproj @@ -20,9 +20,11 @@ AC384BCF23B4FFA700C7BF47 /* PageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC384BCB23B4FFA700C7BF47 /* PageViewController.swift */; }; ACA46529244E403900A5DA7A /* AppType.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACA46528244E403900A5DA7A /* AppType.swift */; }; ACD9BE3C2444A371009014E6 /* ConfigurationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACD9BE2D2444A371009014E6 /* ConfigurationManager.swift */; }; + C0A5E1100000000000000001 /* ConsentManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A5E1100000000000000002 /* ConsentManager.swift */; }; BFFB694BE9E3E744FD4F92E0 /* CrashManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25ACB597DDB073F3AFC74345 /* CrashManager.swift */; }; C43B65A97E4029F7790DA729 /* CoreWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02C00CD0A89EF1F64FF879A1 /* CoreWrapper.swift */; }; D2DCD7104EBCC0F1A784E116 /* GoogleMobileAds in Frameworks */ = {isa = PBXBuildFile; productRef = B5F033C5AA89A6E9C19EA27D /* GoogleMobileAds */; }; + C0A5E1100000000000000005 /* GoogleUserMessagingPlatform in Frameworks */ = {isa = PBXBuildFile; productRef = C0A5E1100000000000000004 /* GoogleUserMessagingPlatform */; }; E1A78CF72C1A53DB00CD43E4 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E1EB6C492C1A510D003EC5A0 /* Foundation.framework */; }; E2064DFE22CFA1BA006441F8 /* iAd.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E2064DDF22CFA1BA006441F8 /* iAd.framework */; }; E22B252F2557F0E2001D0C52 /* OpenDocumentReaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22B252E2557F0E2001D0C52 /* OpenDocumentReaderTests.swift */; }; @@ -87,6 +89,7 @@ AC73790F2438E52B00F9F3B5 /* sl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sl; path = sl.lproj/Localizable.strings; sourceTree = ""; }; ACA46528244E403900A5DA7A /* AppType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppType.swift; sourceTree = ""; }; ACD9BE2D2444A371009014E6 /* ConfigurationManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConfigurationManager.swift; sourceTree = ""; }; + C0A5E1100000000000000002 /* ConsentManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConsentManager.swift; sourceTree = ""; }; ACF1A3E42469F8DE000BA420 /* Info-Lite.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "Info-Lite.plist"; sourceTree = ""; }; B01C1B2BA00A7917FF7D7726 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; B94897965C815527C6C06996 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; @@ -138,6 +141,7 @@ 523A371328CCF27400876C77 /* AdServices.framework in Frameworks */, E1A78CF72C1A53DB00CD43E4 /* Foundation.framework in Frameworks */, D2DCD7104EBCC0F1A784E116 /* GoogleMobileAds in Frameworks */, + C0A5E1100000000000000005 /* GoogleUserMessagingPlatform in Frameworks */, FB494C85E264BD924C1EA54E /* OdrCore in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -244,6 +248,7 @@ AC384BC923B4FFA700C7BF47 /* ContentViewController.swift */, AC384BCB23B4FFA700C7BF47 /* PageViewController.swift */, ACD9BE2D2444A371009014E6 /* ConfigurationManager.swift */, + C0A5E1100000000000000002 /* ConsentManager.swift */, E22EB717226B633500053B86 /* Document.swift */, E22EB71B226B66B300053B86 /* Main.storyboard */, E2F7ED5D220B54D700D63515 /* Assets.xcassets */, @@ -296,6 +301,7 @@ name = OpenDocumentReader; packageProductDependencies = ( B5F033C5AA89A6E9C19EA27D /* GoogleMobileAds */, + C0A5E1100000000000000004 /* GoogleUserMessagingPlatform */, E381918B979121CD165C966A /* OdrCore */, ); productName = "OpenDocument Reader"; @@ -349,6 +355,7 @@ mainGroup = E2F7ED46220B54D600D63515; packageReferences = ( AD584FCD41577C8CDEE974AA /* XCRemoteSwiftPackageReference "swift-package-manager-google-mobile-ads" */, + C0A5E1100000000000000003 /* XCRemoteSwiftPackageReference "swift-package-manager-google-user-messaging-platform" */, 78200B97C10509E2539B4FE5 /* XCRemoteSwiftPackageReference "OpenDocument.core" */, ); productRefGroup = E2F7ED46220B54D600D63515; @@ -410,6 +417,7 @@ E22EB716226B621200053B86 /* DocumentBrowserTransitioningDelegate.swift in Sources */, ACA46529244E403900A5DA7A /* AppType.swift in Sources */, ACD9BE3C2444A371009014E6 /* ConfigurationManager.swift in Sources */, + C0A5E1100000000000000001 /* ConsentManager.swift in Sources */, AC384BCE23B4FFA700C7BF47 /* Constants.swift in Sources */, E2F7ED55220B54D600D63515 /* DocumentBrowserViewController.swift in Sources */, E2F7ED53220B54D600D63515 /* AppDelegate.swift in Sources */, @@ -978,6 +986,14 @@ minimumVersion = 13.7.0; }; }; + C0A5E1100000000000000003 /* XCRemoteSwiftPackageReference "swift-package-manager-google-user-messaging-platform" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/googleads/swift-package-manager-google-user-messaging-platform.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 3.1.0; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -991,6 +1007,11 @@ package = 78200B97C10509E2539B4FE5 /* XCRemoteSwiftPackageReference "OpenDocument.core" */; productName = OdrCore; }; + C0A5E1100000000000000004 /* GoogleUserMessagingPlatform */ = { + isa = XCSwiftPackageProductDependency; + package = C0A5E1100000000000000003 /* XCRemoteSwiftPackageReference "swift-package-manager-google-user-messaging-platform" */; + productName = GoogleUserMessagingPlatform; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = E2F7ED47220B54D600D63515 /* Project object */; diff --git a/OpenDocumentReader/ConsentManager.swift b/OpenDocumentReader/ConsentManager.swift new file mode 100644 index 0000000..7c60af1 --- /dev/null +++ b/OpenDocumentReader/ConsentManager.swift @@ -0,0 +1,72 @@ +/* +See LICENSE folder for this sample’s licensing information. + +Abstract: +Gathers advertising consent through Google's User Messaging Platform. +*/ + +import UIKit +import UserMessagingPlatform + +/// The consent form in front of the banner in the Lite configuration. +/// +/// Google's EU user consent policy requires a certified CMP, integrated with IAB TCF, for traffic +/// from the EEA, the UK and Switzerland. UMP is Google's own, and it is what the Android app has +/// used since it shipped its privacy messaging. +/// +/// Which users are asked is decided by UMP, not here: the messages are geo-targeted in AdMob under +/// Privacy & messaging, and the SDK resolves the user's region server-side. Outside a configured +/// region nothing is presented and `canRequestAds` is simply true. +final class ConsentManager { + + static let manager = ConsentManager() + + private init() {} + + /// Brings consent up to date, presenting the form if the user's region requires one, and then + /// reports whether an ad may be requested. + /// + /// Whether we may load an ad is `canRequestAds` and nothing else - never whether the calls + /// themselves came back clean. The SDK caches the user's decision, so a form that fails to + /// present, or an update that times out because the device is offline, still leaves an earlier + /// consent standing, and outside the regions where a form is required at all there is no + /// decision to fail in the first place. Treating those errors as a refusal would hide the + /// banner from users who had already said yes. + /// + /// The completion runs on the main queue. + func gatherConsent(from viewController: UIViewController, completion: @escaping (Bool) -> Void) { + let parameters = RequestParameters() + parameters.isTaggedForUnderAgeOfConsent = false + + ConsentInformation.shared.requestConsentInfoUpdate(with: parameters) { requestError in + if let requestError = requestError { + // fires for the mundane offline case too - a device that was asleep + // times out against fundingchoicesmessages.google.com + print("consent info update failed: \(requestError.localizedDescription)") + + self.finish(completion) + return + } + + ConsentForm.loadAndPresentIfRequired(from: viewController) { formError in + if let formError = formError { + print("consent form failed: \(formError.localizedDescription)") + } + + self.finish(completion) + } + } + } + + private func finish(_ completion: @escaping (Bool) -> Void) { + let canRequestAds = ConsentInformation.shared.canRequestAds + + if !canRequestAds { + print("consent does not allow requesting ads") + } + + DispatchQueue.main.async { + completion(canRequestAds) + } + } +} diff --git a/OpenDocumentReader/DocumentViewController.swift b/OpenDocumentReader/DocumentViewController.swift index 04ddc34..97a18f5 100644 --- a/OpenDocumentReader/DocumentViewController.swift +++ b/OpenDocumentReader/DocumentViewController.swift @@ -18,6 +18,7 @@ class DocumentViewController: UIViewController, DocumentDelegate, BannerViewDele { private var browserTransition: DocumentBrowserTransitioningDelegate? + private var hasGatheredConsent = false public var transitionController: UIDocumentBrowserTransitionController? { didSet { if let controller = transitionController { @@ -139,14 +140,37 @@ class DocumentViewController: UIViewController, DocumentDelegate, BannerViewDele bannerView.delegate = self bannerView.adUnitID = "ca-app-pub-8161473686436957/8123543897" bannerView.rootViewController = self + } else { + hideBannerView() + } + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + // the consent form is presented modally, so it has to wait until this controller + // is actually in the window hierarchy - viewWillAppear is too early. Both this and + // viewWillAppear run again on every reappearance; the ask itself is once per + // controller, and UMP only presents a form when it still needs an answer. + guard ConfigurationManager.manager.configuration == .lite, !hasGatheredConsent else { + return + } + hasGatheredConsent = true + + ConsentManager.manager.gatherConsent(from: self) { canRequestAds in + guard canRequestAds else { + self.hideBannerView() + return + } + // ATT is Apple's separate question about the IDFA and does not stand in for + // consent under the EU rules. It is only worth putting in front of someone who + // is going to be shown an ad at all, so it follows the consent form. ATTrackingManager.requestTrackingAuthorization(completionHandler: { _ in DispatchQueue.main.async { self.loadBannerAd() } }) - } else { - hideBannerView() } } @@ -192,10 +216,6 @@ class DocumentViewController: UIViewController, DocumentDelegate, BannerViewDele hideBannerView() } - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - } - override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) From b632bc422334e0f87b975a013aa10ec0db7c2e44 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Thu, 6 Aug 2026 21:00:12 +0200 Subject: [PATCH 2/5] Serve limited ads on refusal, and give the choice a way back 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) Claude-Session: https://claude.ai/code/session_018Ens9c7zX2bDxXH8tUL4mG --- OpenDocumentReader/ConsentManager.swift | 73 ++++++++++++++++--- .../DocumentBrowserViewController.swift | 72 ++++++++++++++++++ .../DocumentViewController.swift | 14 +++- .../en.lproj/Localizable.strings | 15 ++++ 4 files changed, 163 insertions(+), 11 deletions(-) diff --git a/OpenDocumentReader/ConsentManager.swift b/OpenDocumentReader/ConsentManager.swift index 7c60af1..b56b494 100644 --- a/OpenDocumentReader/ConsentManager.swift +++ b/OpenDocumentReader/ConsentManager.swift @@ -35,6 +35,65 @@ final class ConsentManager { /// /// The completion runs on the main queue. func gatherConsent(from viewController: UIViewController, completion: @escaping (Bool) -> Void) { + requestUpdate { updated in + guard updated else { + self.finish(completion) + return + } + + ConsentForm.loadAndPresentIfRequired(from: viewController) { formError in + if let formError = formError { + CrashManager.shared.log("consent form failed: \(formError.localizedDescription)") + } + + self.finish(completion) + } + } + } + + /// Brings consent up to date without ever presenting a form. + /// + /// Runs on every launch from the document browser, because `privacyOptionsRequirementStatus` + /// and `canRequestAds` answer from a cache that is only filled by an update completing in the + /// *current* session. Skip it and the privacy entry point disappears on the second launch. + /// + /// The completion runs on the main queue. + func refresh(completion: @escaping () -> Void) { + requestUpdate { _ in + DispatchQueue.main.async { + completion() + } + } + } + + /// Whether this user has a consent choice worth reopening. + /// + /// False outside the regions where a form is configured at all - there is nothing to show, so + /// the entry point offering it should not be there either. + var privacyOptionsRequired: Bool { + ConsentInformation.shared.privacyOptionsRequirementStatus == .required + } + + /// Reopens the consent form so a decision can be changed or withdrawn. + /// + /// GDPR Art. 7(3) wants withdrawing consent to be as easy as giving it, and TCF requires the + /// CMP to be reachable again. Only call this in response to the user asking for it. + /// + /// The completion runs on the main queue. + func presentPrivacyOptions(from viewController: UIViewController, completion: @escaping () -> Void) { + ConsentForm.presentPrivacyOptionsForm(from: viewController) { formError in + if let formError = formError { + CrashManager.shared.log("privacy options form failed: \(formError.localizedDescription)") + } + + DispatchQueue.main.async { + completion() + } + } + } + + /// Reports whether the update succeeded; a failure is logged, never treated as a refusal. + private func requestUpdate(_ completion: @escaping (Bool) -> Void) { let parameters = RequestParameters() parameters.isTaggedForUnderAgeOfConsent = false @@ -42,19 +101,13 @@ final class ConsentManager { if let requestError = requestError { // fires for the mundane offline case too - a device that was asleep // times out against fundingchoicesmessages.google.com - print("consent info update failed: \(requestError.localizedDescription)") + CrashManager.shared.log("consent info update failed: \(requestError.localizedDescription)") - self.finish(completion) + completion(false) return } - ConsentForm.loadAndPresentIfRequired(from: viewController) { formError in - if let formError = formError { - print("consent form failed: \(formError.localizedDescription)") - } - - self.finish(completion) - } + completion(true) } } @@ -62,7 +115,7 @@ final class ConsentManager { let canRequestAds = ConsentInformation.shared.canRequestAds if !canRequestAds { - print("consent does not allow requesting ads") + CrashManager.shared.log("consent does not allow personalised or non-personalised ads; limited ads only") } DispatchQueue.main.async { diff --git a/OpenDocumentReader/DocumentBrowserViewController.swift b/OpenDocumentReader/DocumentBrowserViewController.swift index b908749..e83cc24 100644 --- a/OpenDocumentReader/DocumentBrowserViewController.swift +++ b/OpenDocumentReader/DocumentBrowserViewController.swift @@ -26,6 +26,10 @@ class DocumentBrowserViewController: UIDocumentBrowserViewController, UIDocument StoreReviewHelper.checkAndAskForReview() + // ahead of the intro guard below: this has to run on every launch, and most launches + // return there + refreshPrivacyButton() + let userDefaults = UserDefaults.standard let wasIntroWatched = userDefaults.bool(forKey: Constants.key_was_intro_watched) @@ -37,6 +41,74 @@ class DocumentBrowserViewController: UIDocumentBrowserViewController, UIDocument } } + // MARK: - Privacy + + /// Brings consent up to date and then shows or hides the entry point that reopens it. + /// + /// The app has no settings screen, so the browser's own chrome carries this. It is the only + /// route back to either choice: the consent form is shown once, and ATT is one-shot per + /// install. + private func refreshPrivacyButton() { + guard ConfigurationManager.manager.configuration == .lite else { return } + + ConsentManager.manager.refresh { + let item = UIBarButtonItem( + title: NSLocalizedString("privacy", comment: ""), + style: .plain, + target: self, + action: #selector(self.showPrivacyOptions(_:)) + ) + + self.additionalTrailingNavigationBarButtonItems = [item] + } + } + + @objc private func showPrivacyOptions(_ sender: UIBarButtonItem) { + let sheet = UIAlertController( + title: NSLocalizedString("privacy", comment: ""), message: nil, preferredStyle: .actionSheet) + + // absent outside the regions UMP has a message configured for, where there is no + // decision on file and nothing for the form to show + if ConsentManager.manager.privacyOptionsRequired { + sheet.addAction( + UIAlertAction(title: NSLocalizedString("privacy_ad_choices", comment: ""), style: .default) { _ in + ConsentManager.manager.presentPrivacyOptions(from: self) {} + }) + } + + sheet.addAction( + UIAlertAction(title: NSLocalizedString("privacy_tracking", comment: ""), style: .default) { _ in + self.showTrackingPermissionHint() + }) + + sheet.addAction(UIAlertAction(title: NSLocalizedString("cancel", comment: ""), style: .cancel)) + + // an action sheet without this crashes on iPad, where it is a popover + sheet.popoverPresentationController?.barButtonItem = sender + + present(sheet, animated: true) + } + + /// ATT cannot be asked twice, so the only way back is the Settings app. + private func showTrackingPermissionHint() { + let alert = UIAlertController( + title: NSLocalizedString("privacy_tracking", comment: ""), + message: NSLocalizedString("privacy_tracking_message", comment: ""), + preferredStyle: .alert + ) + + alert.addAction( + UIAlertAction(title: NSLocalizedString("privacy_open_settings", comment: ""), style: .default) { _ in + guard let url = URL(string: UIApplication.openSettingsURLString) else { return } + + UIApplication.shared.open(url) + }) + + alert.addAction(UIAlertAction(title: NSLocalizedString("cancel", comment: ""), style: .cancel)) + + present(alert, animated: true) + } + func documentBrowser( _ controller: UIDocumentBrowserViewController, didImportDocumentAt sourceURL: URL, toDestinationURL destinationURL: URL diff --git a/OpenDocumentReader/DocumentViewController.swift b/OpenDocumentReader/DocumentViewController.swift index 97a18f5..0fc0ccf 100644 --- a/OpenDocumentReader/DocumentViewController.swift +++ b/OpenDocumentReader/DocumentViewController.swift @@ -159,13 +159,25 @@ class DocumentViewController: UIViewController, DocumentDelegate, BannerViewDele ConsentManager.manager.gatherConsent(from: self) { canRequestAds in guard canRequestAds else { - self.hideBannerView() + // Refused, but still worth asking for: the "do not consent" answer emits a TC + // string carrying the special purposes, from which Google selects limited ads + // server-side - no cookies, no identifiers, no local storage. Showing nothing + // here would be stricter than the rules require and costs the fill outright. + // + // No ATT on this path. Limited ads use no advertising identifier, so there is + // nothing for Apple's question to govern and asking it would contradict the + // answer the user just gave. + self.loadBannerAd() return } // ATT is Apple's separate question about the IDFA and does not stand in for // consent under the EU rules. It is only worth putting in front of someone who // is going to be shown an ad at all, so it follows the consent form. + // + // Asked even when the user allowed storage but refused personalisation: a + // non-personalised ad still uses the identifier for frequency capping and + // aggregated reporting across apps, which is what ATT actually gates. ATTrackingManager.requestTrackingAuthorization(completionHandler: { _ in DispatchQueue.main.async { self.loadBannerAd() diff --git a/OpenDocumentReader/en.lproj/Localizable.strings b/OpenDocumentReader/en.lproj/Localizable.strings index 89b00c1..157c5ce 100644 --- a/OpenDocumentReader/en.lproj/Localizable.strings +++ b/OpenDocumentReader/en.lproj/Localizable.strings @@ -77,3 +77,18 @@ "intro_next" = "Next"; "intro_skip" = "Skip"; "intro_start" = "Start"; + +/* Entry point in the document browser that reopens the advertising consent choices */ +"privacy" = "Privacy"; + +/* Reopens the Google UMP consent form */ +"privacy_ad_choices" = "Ad privacy choices"; + +/* Leads to the iOS tracking permission for this app */ +"privacy_tracking" = "Tracking permission"; + +/* Explains that ATT is asked once per install and lives in iOS Settings afterwards */ +"privacy_tracking_message" = "iOS asks for tracking permission once. You can change it any time in Settings, under Privacy & Security → Tracking. Changing it there closes the app."; + +/* Opens this app's page in the Settings app */ +"privacy_open_settings" = "Open Settings"; From 1c5889468de3fa8a52f22483ae93272e9a8c9be7 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Thu, 6 Aug 2026 21:49:42 +0200 Subject: [PATCH 3/5] Fall back to English when a privacy string is untranslated 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) Claude-Session: https://claude.ai/code/session_018Ens9c7zX2bDxXH8tUL4mG --- .../DocumentBrowserViewController.swift | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/OpenDocumentReader/DocumentBrowserViewController.swift b/OpenDocumentReader/DocumentBrowserViewController.swift index e83cc24..39597e0 100644 --- a/OpenDocumentReader/DocumentBrowserViewController.swift +++ b/OpenDocumentReader/DocumentBrowserViewController.swift @@ -53,7 +53,7 @@ class DocumentBrowserViewController: UIDocumentBrowserViewController, UIDocument ConsentManager.manager.refresh { let item = UIBarButtonItem( - title: NSLocalizedString("privacy", comment: ""), + title: NSLocalizedString("privacy", value: "Privacy", comment: ""), style: .plain, target: self, action: #selector(self.showPrivacyOptions(_:)) @@ -65,19 +65,25 @@ class DocumentBrowserViewController: UIDocumentBrowserViewController, UIDocument @objc private func showPrivacyOptions(_ sender: UIBarButtonItem) { let sheet = UIAlertController( - title: NSLocalizedString("privacy", comment: ""), message: nil, preferredStyle: .actionSheet) + title: NSLocalizedString("privacy", value: "Privacy", comment: ""), message: nil, + preferredStyle: .actionSheet) // absent outside the regions UMP has a message configured for, where there is no // decision on file and nothing for the form to show if ConsentManager.manager.privacyOptionsRequired { sheet.addAction( - UIAlertAction(title: NSLocalizedString("privacy_ad_choices", comment: ""), style: .default) { _ in + UIAlertAction( + title: NSLocalizedString("privacy_ad_choices", value: "Ad privacy choices", comment: ""), + style: .default + ) { _ in ConsentManager.manager.presentPrivacyOptions(from: self) {} }) } sheet.addAction( - UIAlertAction(title: NSLocalizedString("privacy_tracking", comment: ""), style: .default) { _ in + UIAlertAction( + title: NSLocalizedString("privacy_tracking", value: "Tracking permission", comment: ""), style: .default + ) { _ in self.showTrackingPermissionHint() }) @@ -92,13 +98,19 @@ class DocumentBrowserViewController: UIDocumentBrowserViewController, UIDocument /// ATT cannot be asked twice, so the only way back is the Settings app. private func showTrackingPermissionHint() { let alert = UIAlertController( - title: NSLocalizedString("privacy_tracking", comment: ""), - message: NSLocalizedString("privacy_tracking_message", comment: ""), + title: NSLocalizedString("privacy_tracking", value: "Tracking permission", comment: ""), + message: NSLocalizedString( + "privacy_tracking_message", + value: + "iOS asks for tracking permission once. You can change it any time in Settings, under Privacy & Security → Tracking. Changing it there closes the app.", + comment: ""), preferredStyle: .alert ) alert.addAction( - UIAlertAction(title: NSLocalizedString("privacy_open_settings", comment: ""), style: .default) { _ in + UIAlertAction( + title: NSLocalizedString("privacy_open_settings", value: "Open Settings", comment: ""), style: .default + ) { _ in guard let url = URL(string: UIApplication.openSettingsURLString) else { return } UIApplication.shared.open(url) From 83815a5fb8056dd908a5e5fe5be925665358eedc Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Thu, 6 Aug 2026 22:02:35 +0200 Subject: [PATCH 4/5] Keep the banner away until an answer is actually on file 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. --- OpenDocumentReader/ConsentManager.swift | 26 ++++++++++++++++++- .../DocumentViewController.swift | 19 ++++++++++---- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/OpenDocumentReader/ConsentManager.swift b/OpenDocumentReader/ConsentManager.swift index b56b494..1671da8 100644 --- a/OpenDocumentReader/ConsentManager.swift +++ b/OpenDocumentReader/ConsentManager.swift @@ -33,6 +33,10 @@ final class ConsentManager { /// decision to fail in the first place. Treating those errors as a refusal would hide the /// banner from users who had already said yes. /// + /// It is not a report of what the user chose, either: the property says only that an answer + /// has been gathered, and "do not consent" is an answer. What that answer allows is + /// `adsMayUseAdvertisingIdentifier` below. + /// /// The completion runs on the main queue. func gatherConsent(from viewController: UIViewController, completion: @escaping (Bool) -> Void) { requestUpdate { updated in @@ -66,6 +70,26 @@ final class ConsentManager { } } + /// Whether an ad shown to this user may carry an advertising identifier, and so whether ATT + /// has anything to govern. + /// + /// The CMP writes the TCF signals into `UserDefaults`, as the framework requires. The first + /// flag of `IABTCF_PurposeConsents` is purpose 1, storing and reading information on the + /// device; without it Google can serve neither personalised nor non-personalised ads and falls + /// back to limited ads, which carry no identifier at all. A user who allowed purpose 1 and + /// refused the personalisation purposes is still shown ads keyed to the identifier for + /// frequency capping and cross-app reporting, which is what ATT actually gates. + /// + /// True where the keys are absent: outside the TCF regions the identifier is used as it + /// always was. + var adsMayUseAdvertisingIdentifier: Bool { + guard let purposeConsents = UserDefaults.standard.string(forKey: "IABTCF_PurposeConsents") else { + return true + } + + return purposeConsents.first == "1" + } + /// Whether this user has a consent choice worth reopening. /// /// False outside the regions where a form is configured at all - there is nothing to show, so @@ -115,7 +139,7 @@ final class ConsentManager { let canRequestAds = ConsentInformation.shared.canRequestAds if !canRequestAds { - CrashManager.shared.log("consent does not allow personalised or non-personalised ads; limited ads only") + CrashManager.shared.log("no consent gathered; not requesting an ad") } DispatchQueue.main.async { diff --git a/OpenDocumentReader/DocumentViewController.swift b/OpenDocumentReader/DocumentViewController.swift index 0fc0ccf..3fc3f09 100644 --- a/OpenDocumentReader/DocumentViewController.swift +++ b/OpenDocumentReader/DocumentViewController.swift @@ -159,12 +159,21 @@ class DocumentViewController: UIViewController, DocumentDelegate, BannerViewDele ConsentManager.manager.gatherConsent(from: self) { canRequestAds in guard canRequestAds else { - // Refused, but still worth asking for: the "do not consent" answer emits a TC - // string carrying the special purposes, from which Google selects limited ads - // server-side - no cookies, no identifiers, no local storage. Showing nothing - // here would be stricter than the rules require and costs the fill outright. + // No answer on file at all - the form could not be presented, or a first launch + // in a region that requires one came up offline. This is not the refusal path: + // "do not consent" is still an answer, and leaves this true. With no consent + // signal to send, no ad may be requested; a later launch will gather one. + self.hideBannerView() + return + } + + guard ConsentManager.manager.adsMayUseAdvertisingIdentifier else { + // Refused, but still worth serving: the answer emits a TC string carrying the + // special purposes, from which Google selects limited ads server-side - no + // identifiers, no personalisation. Showing nothing here would be stricter than + // the rules require and costs the fill outright. // - // No ATT on this path. Limited ads use no advertising identifier, so there is + // No ATT on this path. A limited ad uses no advertising identifier, so there is // nothing for Apple's question to govern and asking it would contradict the // answer the user just gave. self.loadBannerAd() From c535f438631a93144f68c279bfb2c6424b8b8796 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Thu, 6 Aug 2026 22:11:32 +0200 Subject: [PATCH 5/5] Say the same things about consent in half the words 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. --- OpenDocumentReader/ConsentManager.swift | 71 +++++++------------ .../DocumentBrowserViewController.swift | 13 ++-- .../DocumentViewController.swift | 32 +++------ 3 files changed, 39 insertions(+), 77 deletions(-) diff --git a/OpenDocumentReader/ConsentManager.swift b/OpenDocumentReader/ConsentManager.swift index 1671da8..6dc1cf2 100644 --- a/OpenDocumentReader/ConsentManager.swift +++ b/OpenDocumentReader/ConsentManager.swift @@ -10,34 +10,23 @@ import UserMessagingPlatform /// The consent form in front of the banner in the Lite configuration. /// -/// Google's EU user consent policy requires a certified CMP, integrated with IAB TCF, for traffic -/// from the EEA, the UK and Switzerland. UMP is Google's own, and it is what the Android app has -/// used since it shipped its privacy messaging. -/// -/// Which users are asked is decided by UMP, not here: the messages are geo-targeted in AdMob under -/// Privacy & messaging, and the SDK resolves the user's region server-side. Outside a configured -/// region nothing is presented and `canRequestAds` is simply true. +/// Google's EU user consent policy requires a TCF-integrated CMP for the EEA, the UK and +/// Switzerland; UMP is Google's own, and what the Android app uses. Who is asked is decided by the +/// geo-targeting of the messages in AdMob, not here. final class ConsentManager { static let manager = ConsentManager() private init() {} - /// Brings consent up to date, presenting the form if the user's region requires one, and then - /// reports whether an ad may be requested. - /// - /// Whether we may load an ad is `canRequestAds` and nothing else - never whether the calls - /// themselves came back clean. The SDK caches the user's decision, so a form that fails to - /// present, or an update that times out because the device is offline, still leaves an earlier - /// consent standing, and outside the regions where a form is required at all there is no - /// decision to fail in the first place. Treating those errors as a refusal would hide the - /// banner from users who had already said yes. + /// Brings consent up to date, presenting the form where the region requires one. /// - /// It is not a report of what the user chose, either: the property says only that an answer - /// has been gathered, and "do not consent" is an answer. What that answer allows is - /// `adsMayUseAdvertisingIdentifier` below. + /// Reports `canRequestAds`: whether an answer is on file, not what it was - "do not consent" + /// leaves it true. What the answer allows is `adsMayUseAdvertisingIdentifier`. Errors do not + /// enter into it; the decision is cached, so a failed form or an offline update still leaves + /// an earlier consent standing. /// - /// The completion runs on the main queue. + /// Completes on the main queue. func gatherConsent(from viewController: UIViewController, completion: @escaping (Bool) -> Void) { requestUpdate { updated in guard updated else { @@ -57,11 +46,11 @@ final class ConsentManager { /// Brings consent up to date without ever presenting a form. /// - /// Runs on every launch from the document browser, because `privacyOptionsRequirementStatus` - /// and `canRequestAds` answer from a cache that is only filled by an update completing in the - /// *current* session. Skip it and the privacy entry point disappears on the second launch. + /// Needed on every launch: `privacyOptionsRequirementStatus` answers from a cache only an + /// update in the *current* session fills, so skipping it hides the privacy entry point on the + /// second launch. /// - /// The completion runs on the main queue. + /// Completes on the main queue. func refresh(completion: @escaping () -> Void) { requestUpdate { _ in DispatchQueue.main.async { @@ -70,18 +59,13 @@ final class ConsentManager { } } - /// Whether an ad shown to this user may carry an advertising identifier, and so whether ATT - /// has anything to govern. - /// - /// The CMP writes the TCF signals into `UserDefaults`, as the framework requires. The first - /// flag of `IABTCF_PurposeConsents` is purpose 1, storing and reading information on the - /// device; without it Google can serve neither personalised nor non-personalised ads and falls - /// back to limited ads, which carry no identifier at all. A user who allowed purpose 1 and - /// refused the personalisation purposes is still shown ads keyed to the identifier for - /// frequency capping and cross-app reporting, which is what ATT actually gates. + /// Whether an ad shown to this user may carry an advertising identifier, which is what ATT + /// governs. /// - /// True where the keys are absent: outside the TCF regions the identifier is used as it - /// always was. + /// The first flag of the TCF signals UMP writes to `UserDefaults` is purpose 1, device storage. + /// Without it Google falls back to limited ads, which carry no identifier; refusing only + /// personalisation leaves one in play, for frequency capping and cross-app reporting. Absent + /// keys mean no TCF region. var adsMayUseAdvertisingIdentifier: Bool { guard let purposeConsents = UserDefaults.standard.string(forKey: "IABTCF_PurposeConsents") else { return true @@ -90,20 +74,16 @@ final class ConsentManager { return purposeConsents.first == "1" } - /// Whether this user has a consent choice worth reopening. - /// - /// False outside the regions where a form is configured at all - there is nothing to show, so - /// the entry point offering it should not be there either. + /// Whether this user has a consent choice worth reopening. False where no message is + /// configured: nothing to show, so no entry point either. var privacyOptionsRequired: Bool { ConsentInformation.shared.privacyOptionsRequirementStatus == .required } - /// Reopens the consent form so a decision can be changed or withdrawn. - /// - /// GDPR Art. 7(3) wants withdrawing consent to be as easy as giving it, and TCF requires the - /// CMP to be reachable again. Only call this in response to the user asking for it. + /// Reopens the consent form so a decision can be changed or withdrawn, as GDPR Art. 7(3) and + /// TCF require. Only in response to the user asking. /// - /// The completion runs on the main queue. + /// Completes on the main queue. func presentPrivacyOptions(from viewController: UIViewController, completion: @escaping () -> Void) { ConsentForm.presentPrivacyOptionsForm(from: viewController) { formError in if let formError = formError { @@ -123,8 +103,7 @@ final class ConsentManager { ConsentInformation.shared.requestConsentInfoUpdate(with: parameters) { requestError in if let requestError = requestError { - // fires for the mundane offline case too - a device that was asleep - // times out against fundingchoicesmessages.google.com + // offline is the mundane case, timing out against fundingchoicesmessages.google.com CrashManager.shared.log("consent info update failed: \(requestError.localizedDescription)") completion(false) diff --git a/OpenDocumentReader/DocumentBrowserViewController.swift b/OpenDocumentReader/DocumentBrowserViewController.swift index 39597e0..fb23390 100644 --- a/OpenDocumentReader/DocumentBrowserViewController.swift +++ b/OpenDocumentReader/DocumentBrowserViewController.swift @@ -26,8 +26,7 @@ class DocumentBrowserViewController: UIDocumentBrowserViewController, UIDocument StoreReviewHelper.checkAndAskForReview() - // ahead of the intro guard below: this has to run on every launch, and most launches - // return there + // ahead of the intro guard below, which most launches return at refreshPrivacyButton() let userDefaults = UserDefaults.standard @@ -43,11 +42,10 @@ class DocumentBrowserViewController: UIDocumentBrowserViewController, UIDocument // MARK: - Privacy - /// Brings consent up to date and then shows or hides the entry point that reopens it. + /// Brings consent up to date and then offers the way back to it. /// - /// The app has no settings screen, so the browser's own chrome carries this. It is the only - /// route back to either choice: the consent form is shown once, and ATT is one-shot per - /// install. + /// The app has no settings screen, so the browser's chrome carries this - the only route back + /// to either choice, both of which are asked once. private func refreshPrivacyButton() { guard ConfigurationManager.manager.configuration == .lite else { return } @@ -68,8 +66,7 @@ class DocumentBrowserViewController: UIDocumentBrowserViewController, UIDocument title: NSLocalizedString("privacy", value: "Privacy", comment: ""), message: nil, preferredStyle: .actionSheet) - // absent outside the regions UMP has a message configured for, where there is no - // decision on file and nothing for the form to show + // absent where UMP has no message configured, and so nothing to show if ConsentManager.manager.privacyOptionsRequired { sheet.addAction( UIAlertAction( diff --git a/OpenDocumentReader/DocumentViewController.swift b/OpenDocumentReader/DocumentViewController.swift index 3fc3f09..4b6a0a2 100644 --- a/OpenDocumentReader/DocumentViewController.swift +++ b/OpenDocumentReader/DocumentViewController.swift @@ -148,10 +148,8 @@ class DocumentViewController: UIViewController, DocumentDelegate, BannerViewDele override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - // the consent form is presented modally, so it has to wait until this controller - // is actually in the window hierarchy - viewWillAppear is too early. Both this and - // viewWillAppear run again on every reappearance; the ask itself is once per - // controller, and UMP only presents a form when it still needs an answer. + // the form is modal, so it has to wait for the window hierarchy - viewWillAppear is + // too early. This runs again on every reappearance, hence the flag. guard ConfigurationManager.manager.configuration == .lite, !hasGatheredConsent else { return } @@ -159,34 +157,22 @@ class DocumentViewController: UIViewController, DocumentDelegate, BannerViewDele ConsentManager.manager.gatherConsent(from: self) { canRequestAds in guard canRequestAds else { - // No answer on file at all - the form could not be presented, or a first launch - // in a region that requires one came up offline. This is not the refusal path: - // "do not consent" is still an answer, and leaves this true. With no consent - // signal to send, no ad may be requested; a later launch will gather one. + // nothing on file - the form failed, or a first launch offline where one is + // required. Not refusal: "do not consent" is an answer and leaves this true. self.hideBannerView() return } guard ConsentManager.manager.adsMayUseAdvertisingIdentifier else { - // Refused, but still worth serving: the answer emits a TC string carrying the - // special purposes, from which Google selects limited ads server-side - no - // identifiers, no personalisation. Showing nothing here would be stricter than - // the rules require and costs the fill outright. - // - // No ATT on this path. A limited ad uses no advertising identifier, so there is - // nothing for Apple's question to govern and asking it would contradict the - // answer the user just gave. + // refused, and still worth serving: Google selects limited ads server-side from + // the TC string's special purposes, and showing nothing would be stricter than + // the rules require. No ATT - a limited ad carries no identifier to govern. self.loadBannerAd() return } - // ATT is Apple's separate question about the IDFA and does not stand in for - // consent under the EU rules. It is only worth putting in front of someone who - // is going to be shown an ad at all, so it follows the consent form. - // - // Asked even when the user allowed storage but refused personalisation: a - // non-personalised ad still uses the identifier for frequency capping and - // aggregated reporting across apps, which is what ATT actually gates. + // ATT asks about the IDFA and is no substitute for consent under the EU rules, so + // it follows the form, and only for users who get an ad. ATTrackingManager.requestTrackingAuthorization(completionHandler: { _ in DispatchQueue.main.async { self.loadBannerAd()