Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions OpenDocumentReader.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -87,6 +88,7 @@
AC73790F2438E52B00F9F3B5 /* sl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sl; path = sl.lproj/Localizable.strings; sourceTree = "<group>"; };
ACA46528244E403900A5DA7A /* AppType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppType.swift; sourceTree = "<group>"; };
ACD9BE2D2444A371009014E6 /* ConfigurationManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConfigurationManager.swift; sourceTree = "<group>"; };
C0A5E1200000000000000002 /* HouseAdView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HouseAdView.swift; sourceTree = "<group>"; };
ACF1A3E42469F8DE000BA420 /* Info-Lite.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "Info-Lite.plist"; sourceTree = "<group>"; };
B01C1B2BA00A7917FF7D7726 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
B94897965C815527C6C06996 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
Original file line number Diff line number Diff line change
@@ -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 }
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 7 additions & 0 deletions OpenDocumentReader/Constants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
76 changes: 74 additions & 2 deletions OpenDocumentReader/DocumentViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
223 changes: 223 additions & 0 deletions OpenDocumentReader/HouseAdView.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading