From 5f4b2374624a512284a08bfcb3d378881373fdab Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Thu, 6 Aug 2026 21:28:49 +0200 Subject: [PATCH 1/2] Promote the paid app when no ad fills the slot The banner slot collapsed whenever a request came back empty. It now shows our own promotion for ODR Pro instead, rotating through three creatives. This is our own view, not an ad: nothing is fetched, no identifier is read, no storage is touched. So it fills the slot on every no-fill path, including the one where the user refused consent and Google serves limited ads or nothing -- a house campaign in AdMob could not cover that case, since it only fills inside a successful ad request. One layout that sheds parts as the slot narrows, because the banner height is fixed by the ad slot and cannot grow: the subline goes below 360pt, the icon below 300pt, and the headline switches to a short form. Both labels truncate rather than wrap, so a long translation shortens instead of breaking the height. Taps open ODR Pro in an SKStoreProductViewController rather than sending the user out to the App Store app. Note the id is the *paid* app, which on iOS is at.tomtasche.reader -- the same bundle id names the free app on Android. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018Ens9c7zX2bDxXH8tUL4mG --- OpenDocumentReader.xcodeproj/project.pbxproj | 4 + .../house_ad_icon.imageset/Contents.json | 8 + .../house_ad_icon.imageset/icon.png | Bin 0 -> 2489 bytes .../house_ad_icon.imageset/icon@2x.png | Bin 0 -> 5511 bytes .../house_ad_icon.imageset/icon@3x.png | Bin 0 -> 9030 bytes OpenDocumentReader/Constants.swift | 7 + .../DocumentViewController.swift | 76 ++++++- OpenDocumentReader/HouseAdView.swift | 211 ++++++++++++++++++ .../en.lproj/Localizable.strings | 17 ++ 9 files changed, 321 insertions(+), 2 deletions(-) create mode 100644 OpenDocumentReader/Assets.xcassets/house_ad_icon.imageset/Contents.json create mode 100644 OpenDocumentReader/Assets.xcassets/house_ad_icon.imageset/icon.png create mode 100644 OpenDocumentReader/Assets.xcassets/house_ad_icon.imageset/icon@2x.png create mode 100644 OpenDocumentReader/Assets.xcassets/house_ad_icon.imageset/icon@3x.png create mode 100644 OpenDocumentReader/HouseAdView.swift diff --git a/OpenDocumentReader.xcodeproj/project.pbxproj b/OpenDocumentReader.xcodeproj/project.pbxproj index 132e9c0..deda9af 100644 --- a/OpenDocumentReader.xcodeproj/project.pbxproj +++ b/OpenDocumentReader.xcodeproj/project.pbxproj @@ -20,6 +20,7 @@ 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 */; }; + C0A5E1200000000000000001 /* HouseAdView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A5E1200000000000000002 /* HouseAdView.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 */; }; @@ -87,6 +88,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 = ""; }; + C0A5E1200000000000000002 /* HouseAdView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HouseAdView.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 = ""; }; @@ -244,6 +246,7 @@ AC384BC923B4FFA700C7BF47 /* ContentViewController.swift */, AC384BCB23B4FFA700C7BF47 /* PageViewController.swift */, ACD9BE2D2444A371009014E6 /* ConfigurationManager.swift */, + C0A5E1200000000000000002 /* HouseAdView.swift */, E22EB717226B633500053B86 /* Document.swift */, E22EB71B226B66B300053B86 /* Main.storyboard */, E2F7ED5D220B54D700D63515 /* Assets.xcassets */, @@ -410,6 +413,7 @@ E22EB716226B621200053B86 /* DocumentBrowserTransitioningDelegate.swift in Sources */, ACA46529244E403900A5DA7A /* AppType.swift in Sources */, ACD9BE3C2444A371009014E6 /* ConfigurationManager.swift in Sources */, + C0A5E1200000000000000001 /* HouseAdView.swift in Sources */, AC384BCE23B4FFA700C7BF47 /* Constants.swift in Sources */, E2F7ED55220B54D600D63515 /* DocumentBrowserViewController.swift in Sources */, E2F7ED53220B54D600D63515 /* AppDelegate.swift in Sources */, diff --git a/OpenDocumentReader/Assets.xcassets/house_ad_icon.imageset/Contents.json b/OpenDocumentReader/Assets.xcassets/house_ad_icon.imageset/Contents.json new file mode 100644 index 0000000..619eb4e --- /dev/null +++ b/OpenDocumentReader/Assets.xcassets/house_ad_icon.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "filename" : "icon.png", "idiom" : "universal", "scale" : "1x" }, + { "filename" : "icon@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "icon@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/OpenDocumentReader/Assets.xcassets/house_ad_icon.imageset/icon.png b/OpenDocumentReader/Assets.xcassets/house_ad_icon.imageset/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..8c4c9f7dc94b3a8c1bca8a752ccb2c5d3e39b498 GIT binary patch literal 2489 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 D9S!OB literal 0 HcmV?d00001 diff --git a/OpenDocumentReader/Assets.xcassets/house_ad_icon.imageset/icon@2x.png b/OpenDocumentReader/Assets.xcassets/house_ad_icon.imageset/icon@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..59bb8875c1e4c82dc03382030c6f38bd6432e6ed GIT binary patch literal 5511 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 literal 0 HcmV?d00001 diff --git a/OpenDocumentReader/Constants.swift b/OpenDocumentReader/Constants.swift index a0f5115..4df03d8 100644 --- a/OpenDocumentReader/Constants.swift +++ b/OpenDocumentReader/Constants.swift @@ -12,4 +12,11 @@ enum Constants { static let onboardingImages = ["onboard1", "onboard2", "onboard3"] static let key_was_intro_watched = "wasIntroWatched" + + /// Which of the house ad's rotations comes next. + static let key_house_ad_index = "houseAdIndex" + + /// ODR Pro on the App Store. This is the *paid* app: on iOS that is `at.tomtasche.reader`, + /// while on Android the same bundle id names the free one. + static let proAppStoreId = 1_452_061_743 } diff --git a/OpenDocumentReader/DocumentViewController.swift b/OpenDocumentReader/DocumentViewController.swift index 04ddc34..549b757 100644 --- a/OpenDocumentReader/DocumentViewController.swift +++ b/OpenDocumentReader/DocumentViewController.swift @@ -8,13 +8,14 @@ A view controller for displaying and editing documents. import AdSupport import AppTrackingTransparency import GoogleMobileAds +import StoreKit import UIKit import UIKit.UIPrinter import WebKit // taken from: https://developer.apple.com/documentation/uikit/view_controllers/building_a_document_browser-based_app class DocumentViewController: UIViewController, DocumentDelegate, BannerViewDelegate, UISearchBarDelegate, - WKNavigationDelegate + SKStoreProductViewControllerDelegate, WKNavigationDelegate { private var browserTransition: DocumentBrowserTransitioningDelegate? @@ -52,6 +53,10 @@ class DocumentViewController: UIViewController, DocumentDelegate, BannerViewDele @IBOutlet weak var barButtonItem: UIBarButtonItem! @IBOutlet weak var searchButton: UIBarButtonItem! + /// Fills the banner slot when no ad does. Sits on top of `bannerView` rather than in the + /// layout chain, so the slot keeps its height and nothing below it moves. + private let houseAdView = HouseAdView() + private var searchBarHeightWhenShown: NSLayoutConstraint? private var searchBarHeightWhenHidden: NSLayoutConstraint? private lazy var pageTabBarHeight = pageTabBar.heightAnchor.constraint(equalToConstant: 0) @@ -74,6 +79,27 @@ class DocumentViewController: UIViewController, DocumentDelegate, BannerViewDele pageTabBar.addTarget(self, action: #selector(pageSelected(sender:)), for: .valueChanged) webview.navigationDelegate = self + + setUpHouseAd() + } + + /// Here rather than in `setVCconstraints`, which runs on every appearance and would stack up a + /// fresh set of constraints each time. + private func setUpHouseAd() { + houseAdView.isHidden = true + houseAdView.translatesAutoresizingMaskIntoConstraints = false + houseAdView.onTap = { [weak self] in + self?.openProOnAppStore() + } + + view.addSubview(houseAdView) + + NSLayoutConstraint.activate([ + houseAdView.leadingAnchor.constraint(equalTo: bannerView.leadingAnchor), + houseAdView.trailingAnchor.constraint(equalTo: bannerView.trailingAnchor), + houseAdView.topAnchor.constraint(equalTo: bannerView.topAnchor), + houseAdView.bottomAnchor.constraint(equalTo: bannerView.bottomAnchor), + ]) } /// odrcore renders a page when this web view asks for it, so a document @@ -185,11 +211,57 @@ class DocumentViewController: UIViewController, DocumentDelegate, BannerViewDele func hideBannerView() { bannerView.isHidden = true + houseAdView.isHidden = true bannerViewHeight.constant = 0.0 } + /// No ad to show, so the slot promotes the paid app instead of collapsing. + /// + /// This is our own view - nothing is fetched and no identifier is read - so it is as valid on + /// the path where the user refused consent as on the one where an ad request merely came back + /// empty. + private func showHouseAd() { + houseAdView.rotate() + + bannerView.isHidden = true + houseAdView.isHidden = false + + AnalyticsManager.shared.report("house_ad_shown") + } + func bannerView(_ bannerView: BannerView, didFailToReceiveAdWithError error: Error) { - hideBannerView() + showHouseAd() + } + + /// An ad did arrive after all - on a later document, or once the network came back. + func bannerViewDidReceiveAd(_ bannerView: BannerView) { + houseAdView.isHidden = true + bannerView.isHidden = false + } + + private func openProOnAppStore() { + AnalyticsManager.shared.report("house_ad_tapped") + + let store = SKStoreProductViewController() + store.delegate = self + + // presented over the document rather than sending the user out to the App Store app + store.loadProduct(withParameters: [SKStoreProductParameterITunesItemIdentifier: Constants.proAppStoreId]) { + loaded, error in + if let error { + CrashManager.shared.log(error) + } + + guard loaded else { return } + + DispatchQueue.main.async { + self.present(store, animated: true) + } + } + } + + func productViewControllerDidFinish(_ viewController: SKStoreProductViewController) { + viewController.dismiss(animated: true) } override func viewDidAppear(_ animated: Bool) { diff --git a/OpenDocumentReader/HouseAdView.swift b/OpenDocumentReader/HouseAdView.swift new file mode 100644 index 0000000..b256e07 --- /dev/null +++ b/OpenDocumentReader/HouseAdView.swift @@ -0,0 +1,211 @@ +import StoreKit +import UIKit + +/// The promotion for the paid app that takes the banner's place when no ad arrives. +/// +/// This is our own content, not an ad: nothing is fetched, no identifier is read and no storage is +/// touched. That is what makes it usable on every no-fill path, including the one where the user +/// refused consent outright and Google is serving limited ads or nothing at all. +/// +/// One layout, sized to whatever the slot gives it - the banner height is fixed by the ad slot and +/// cannot grow, so parts drop out instead of wrapping. +final class HouseAdView: UIView { + + /// One rotation of the promotion. `shortHeadline` is what a phone-width slot can actually fit. + private struct Creative { + let shortHeadline: String + let headline: String + let subline: String + let callToAction: String + } + + private static let creatives = [ + Creative( + shortHeadline: NSLocalizedString("house_ad_support_short", comment: ""), + headline: NSLocalizedString("house_ad_support_headline", comment: ""), + subline: NSLocalizedString("house_ad_support_subline", comment: ""), + callToAction: NSLocalizedString("house_ad_cta_go_pro", comment: "")), + Creative( + shortHeadline: NSLocalizedString("house_ad_adfree_short", comment: ""), + headline: NSLocalizedString("house_ad_adfree_headline", comment: ""), + subline: NSLocalizedString("house_ad_adfree_subline", comment: ""), + callToAction: NSLocalizedString("house_ad_cta_get_pro", comment: "")), + Creative( + shortHeadline: NSLocalizedString("house_ad_source_short", comment: ""), + headline: NSLocalizedString("house_ad_source_headline", comment: ""), + subline: NSLocalizedString("house_ad_source_subline", comment: ""), + callToAction: NSLocalizedString("house_ad_cta_go_pro", comment: "")), + ] + + /// Round-robin rather than random, so the three are seen evenly and a session does not repeat + /// the same one. Advanced once per presentation, not once per layout pass. + private static func nextCreative() -> Creative { + let defaults = UserDefaults.standard + let index = defaults.integer(forKey: Constants.key_house_ad_index) + + defaults.set((index + 1) % creatives.count, forKey: Constants.key_house_ad_index) + + return creatives[index % creatives.count] + } + + // its own imageset rather than the app icon: UIImage(named:) cannot reliably reach an + // AppIcon.appiconset at runtime + private let iconView = UIImageView(image: UIImage(named: "house_ad_icon")) + private lazy var iconWidth = iconView.widthAnchor.constraint(equalToConstant: 34) + private lazy var iconHeight = iconView.heightAnchor.constraint(equalToConstant: 34) + private let headlineLabel = UILabel() + private let sublineLabel = UILabel() + private let callToActionLabel = PaddedLabel() + private let textStack = UIStackView() + private let stack = UIStackView() + + private var creative = HouseAdView.nextCreative() + + /// Called when the promotion is tapped, so the controller can present the store. + var onTap: (() -> Void)? + + override init(frame: CGRect) { + super.init(frame: frame) + + setUp() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + + setUp() + } + + private func setUp() { + backgroundColor = .secondarySystemBackground + isAccessibilityElement = true + accessibilityTraits = .button + + iconView.contentMode = .scaleAspectFit + iconView.layer.cornerRadius = 6 + iconView.clipsToBounds = true + iconView.setContentHuggingPriority(.required, for: .horizontal) + + headlineLabel.font = .preferredFont(forTextStyle: .footnote).bold() + headlineLabel.adjustsFontForContentSizeCategory = true + // truncate rather than wrap: a long translation must shorten, not break the fixed height + headlineLabel.lineBreakMode = .byTruncatingTail + + sublineLabel.font = .preferredFont(forTextStyle: .caption2) + sublineLabel.adjustsFontForContentSizeCategory = true + sublineLabel.textColor = .secondaryLabel + sublineLabel.lineBreakMode = .byTruncatingTail + + callToActionLabel.font = .preferredFont(forTextStyle: .caption1).bold() + callToActionLabel.adjustsFontForContentSizeCategory = true + callToActionLabel.textColor = .systemBackground + callToActionLabel.backgroundColor = .tintColor + callToActionLabel.textAlignment = .center + callToActionLabel.clipsToBounds = true + callToActionLabel.setContentHuggingPriority(.required, for: .horizontal) + callToActionLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + + textStack.axis = .vertical + textStack.alignment = .fill + textStack.addArrangedSubview(headlineLabel) + textStack.addArrangedSubview(sublineLabel) + + stack.axis = .horizontal + stack.alignment = .center + stack.spacing = 10 + stack.isLayoutMarginsRelativeArrangement = true + stack.directionalLayoutMargins = .init(top: 4, leading: 10, bottom: 4, trailing: 10) + stack.addArrangedSubview(iconView) + stack.addArrangedSubview(textStack) + stack.addArrangedSubview(callToActionLabel) + stack.translatesAutoresizingMaskIntoConstraints = false + + addSubview(stack) + + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: leadingAnchor), + stack.trailingAnchor.constraint(equalTo: trailingAnchor), + stack.topAnchor.constraint(equalTo: topAnchor), + stack.bottomAnchor.constraint(equalTo: bottomAnchor), + iconWidth, + iconHeight, + ]) + + addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(tapped))) + + apply(creative) + } + + /// Picks the next rotation. Called when the promotion is about to be shown, not on every layout + /// pass, so it does not change under the user while they are looking at it. + func rotate() { + creative = HouseAdView.nextCreative() + + apply(creative) + setNeedsLayout() + } + + private func apply(_ creative: Creative) { + sublineLabel.text = creative.subline + callToActionLabel.text = creative.callToAction + + accessibilityLabel = "\(creative.headline). \(creative.subline)" + accessibilityHint = creative.callToAction + } + + override func layoutSubviews() { + super.layoutSubviews() + + let width = bounds.width + let isTall = bounds.height >= 70 + + // the slot is 50pt on a phone and 90pt on a tablet, and everything here has to fit inside + // that; each of these is dropped at the width where it stops fitting + iconView.isHidden = width < 300 + sublineLabel.isHidden = width < 360 + headlineLabel.text = width < 360 ? creative.shortHeadline : creative.headline + + headlineLabel.font = + isTall ? .preferredFont(forTextStyle: .headline) : .preferredFont(forTextStyle: .footnote).bold() + sublineLabel.font = + isTall ? .preferredFont(forTextStyle: .footnote) : .preferredFont(forTextStyle: .caption2) + + // updated, never re-created: activating a fresh pair here would leak one per layout pass + iconWidth.constant = isTall ? 58 : 34 + iconHeight.constant = isTall ? 58 : 34 + stack.spacing = isTall ? 14 : 10 + + callToActionLabel.layer.cornerRadius = callToActionLabel.bounds.height / 2 + } + + @objc private func tapped() { + onTap?() + } +} + +/// A label with room around its text, for the call-to-action pill. +private final class PaddedLabel: UILabel { + + private let insets = UIEdgeInsets(top: 5, left: 12, bottom: 5, right: 12) + + override func drawText(in rect: CGRect) { + super.drawText(in: rect.inset(by: insets)) + } + + override var intrinsicContentSize: CGSize { + let size = super.intrinsicContentSize + + return CGSize( + width: size.width + insets.left + insets.right, + height: size.height + insets.top + insets.bottom) + } +} + +extension UIFont { + + fileprivate func bold() -> UIFont { + guard let descriptor = fontDescriptor.withSymbolicTraits(.traitBold) else { return self } + + return UIFont(descriptor: descriptor, size: 0) + } +} diff --git a/OpenDocumentReader/en.lproj/Localizable.strings b/OpenDocumentReader/en.lproj/Localizable.strings index 89b00c1..f54d18b 100644 --- a/OpenDocumentReader/en.lproj/Localizable.strings +++ b/OpenDocumentReader/en.lproj/Localizable.strings @@ -77,3 +77,20 @@ "intro_next" = "Next"; "intro_skip" = "Skip"; "intro_start" = "Start"; + +/* House ad shown in the banner slot when no ad is available. "short" variants are used on narrow screens. */ +"house_ad_support_short" = "Support us"; +"house_ad_support_headline" = "Support OpenDocument Reader"; +"house_ad_support_subline" = "Get Pro — no ads, ever"; + +"house_ad_adfree_short" = "Read without ads"; +"house_ad_adfree_headline" = "Read without ads"; +"house_ad_adfree_subline" = "ODR Pro — a one-time purchase"; + +"house_ad_source_short" = "Open source"; +"house_ad_source_headline" = "Open source, kept free"; +"house_ad_source_subline" = "Pro pays for it — and drops the ads"; + +/* Buttons on the house ad */ +"house_ad_cta_go_pro" = "Go Pro"; +"house_ad_cta_get_pro" = "Get Pro"; From bbe19ccfbcba15b0f775f688f59907d7be160f73 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Thu, 6 Aug 2026 21:39:51 +0200 Subject: [PATCH 2/2] Fall back to English when a house ad string is untranslated The house_ad_* 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 every one of the other 16 localizations would have shown "house_ad_support_short" in the banner rather than any readable copy. Passing value: gives each lookup its English text as the fallback, so an untranslated locale reads correctly and a translation still wins once one lands. Reported by Codex on #138. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018Ens9c7zX2bDxXH8tUL4mG --- OpenDocumentReader/HouseAdView.swift | 36 ++++++++++++++++++---------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/OpenDocumentReader/HouseAdView.swift b/OpenDocumentReader/HouseAdView.swift index b256e07..97a1c08 100644 --- a/OpenDocumentReader/HouseAdView.swift +++ b/OpenDocumentReader/HouseAdView.swift @@ -21,20 +21,32 @@ final class HouseAdView: UIView { private static let creatives = [ Creative( - shortHeadline: NSLocalizedString("house_ad_support_short", comment: ""), - headline: NSLocalizedString("house_ad_support_headline", comment: ""), - subline: NSLocalizedString("house_ad_support_subline", comment: ""), - callToAction: NSLocalizedString("house_ad_cta_go_pro", comment: "")), + shortHeadline: NSLocalizedString( + "house_ad_support_short", value: "Support us", comment: ""), + headline: NSLocalizedString( + "house_ad_support_headline", value: "Support OpenDocument Reader", comment: ""), + subline: NSLocalizedString( + "house_ad_support_subline", value: "Get Pro — no ads, ever", comment: ""), + callToAction: NSLocalizedString( + "house_ad_cta_go_pro", value: "Go Pro", comment: "")), Creative( - shortHeadline: NSLocalizedString("house_ad_adfree_short", comment: ""), - headline: NSLocalizedString("house_ad_adfree_headline", comment: ""), - subline: NSLocalizedString("house_ad_adfree_subline", comment: ""), - callToAction: NSLocalizedString("house_ad_cta_get_pro", comment: "")), + shortHeadline: NSLocalizedString( + "house_ad_adfree_short", value: "Read without ads", comment: ""), + headline: NSLocalizedString( + "house_ad_adfree_headline", value: "Read without ads", comment: ""), + subline: NSLocalizedString( + "house_ad_adfree_subline", value: "ODR Pro — a one-time purchase", comment: ""), + callToAction: NSLocalizedString( + "house_ad_cta_get_pro", value: "Get Pro", comment: "")), Creative( - shortHeadline: NSLocalizedString("house_ad_source_short", comment: ""), - headline: NSLocalizedString("house_ad_source_headline", comment: ""), - subline: NSLocalizedString("house_ad_source_subline", comment: ""), - callToAction: NSLocalizedString("house_ad_cta_go_pro", comment: "")), + shortHeadline: NSLocalizedString( + "house_ad_source_short", value: "Open source", comment: ""), + headline: NSLocalizedString( + "house_ad_source_headline", value: "Open source, kept free", comment: ""), + subline: NSLocalizedString( + "house_ad_source_subline", value: "Pro pays for it — and drops the ads", comment: ""), + callToAction: NSLocalizedString( + "house_ad_cta_go_pro", value: "Go Pro", comment: "")), ] /// Round-robin rather than random, so the three are seen evenly and a session does not repeat