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 0000000..8c4c9f7 Binary files /dev/null and b/OpenDocumentReader/Assets.xcassets/house_ad_icon.imageset/icon.png differ 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 0000000..59bb887 Binary files /dev/null and b/OpenDocumentReader/Assets.xcassets/house_ad_icon.imageset/icon@2x.png differ diff --git a/OpenDocumentReader/Assets.xcassets/house_ad_icon.imageset/icon@3x.png b/OpenDocumentReader/Assets.xcassets/house_ad_icon.imageset/icon@3x.png new file mode 100644 index 0000000..4490948 Binary files /dev/null and b/OpenDocumentReader/Assets.xcassets/house_ad_icon.imageset/icon@3x.png differ 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..97a1c08 --- /dev/null +++ b/OpenDocumentReader/HouseAdView.swift @@ -0,0 +1,223 @@ +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", 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", 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", 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 + /// 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";