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..6dc1cf2 --- /dev/null +++ b/OpenDocumentReader/ConsentManager.swift @@ -0,0 +1,128 @@ +/* +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 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 where the region requires one. + /// + /// 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. + /// + /// Completes 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. + /// + /// 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. + /// + /// Completes on the main queue. + func refresh(completion: @escaping () -> Void) { + requestUpdate { _ in + DispatchQueue.main.async { + completion() + } + } + } + + /// Whether an ad shown to this user may carry an advertising identifier, which is what ATT + /// governs. + /// + /// 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 + } + + return purposeConsents.first == "1" + } + + /// 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, as GDPR Art. 7(3) and + /// TCF require. Only in response to the user asking. + /// + /// Completes 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 + + ConsentInformation.shared.requestConsentInfoUpdate(with: parameters) { requestError in + if let requestError = requestError { + // offline is the mundane case, timing out against fundingchoicesmessages.google.com + CrashManager.shared.log("consent info update failed: \(requestError.localizedDescription)") + + completion(false) + return + } + + completion(true) + } + } + + private func finish(_ completion: @escaping (Bool) -> Void) { + let canRequestAds = ConsentInformation.shared.canRequestAds + + if !canRequestAds { + CrashManager.shared.log("no consent gathered; not requesting an ad") + } + + DispatchQueue.main.async { + completion(canRequestAds) + } + } +} diff --git a/OpenDocumentReader/DocumentBrowserViewController.swift b/OpenDocumentReader/DocumentBrowserViewController.swift index b908749..fb23390 100644 --- a/OpenDocumentReader/DocumentBrowserViewController.swift +++ b/OpenDocumentReader/DocumentBrowserViewController.swift @@ -26,6 +26,9 @@ class DocumentBrowserViewController: UIDocumentBrowserViewController, UIDocument StoreReviewHelper.checkAndAskForReview() + // ahead of the intro guard below, which most launches return at + refreshPrivacyButton() + let userDefaults = UserDefaults.standard let wasIntroWatched = userDefaults.bool(forKey: Constants.key_was_intro_watched) @@ -37,6 +40,84 @@ class DocumentBrowserViewController: UIDocumentBrowserViewController, UIDocument } } + // MARK: - Privacy + + /// Brings consent up to date and then offers the way back to it. + /// + /// 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 } + + ConsentManager.manager.refresh { + let item = UIBarButtonItem( + title: NSLocalizedString("privacy", value: "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", value: "Privacy", comment: ""), message: nil, + preferredStyle: .actionSheet) + + // absent where UMP has no message configured, and so nothing to show + if ConsentManager.manager.privacyOptionsRequired { + sheet.addAction( + 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", value: "Tracking permission", 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", 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", value: "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 04ddc34..4b6a0a2 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,44 @@ 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 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 + } + hasGatheredConsent = true + + ConsentManager.manager.gatherConsent(from: self) { canRequestAds in + guard canRequestAds else { + // 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, 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 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() } }) - } else { - hideBannerView() } } @@ -192,10 +223,6 @@ class DocumentViewController: UIViewController, DocumentDelegate, BannerViewDele hideBannerView() } - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - } - override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) 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";