Skip to content
Merged
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
8 changes: 2 additions & 6 deletions .github/workflows/build_test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,7 @@ jobs:
- name: run tests
run: bundle exec fastlane tests

# taking the pictures is a quarter hour of simulators and belongs to the
# release, but a screenshot target that no longer compiles should not wait
# for one to say so
# Compile screenshot tests here; capture them during release.
- name: build the screenshot tests
run: >
xcodebuild build-for-testing
Expand All @@ -62,9 +60,7 @@ jobs:
path: |
/Users/runner/Library/Developer/Xcode/DerivedData/OpenDocumentReader-*/

# the test job only builds the simulator slice of one target, so release-only
# and device-only breakage used to surface at deploy time. Both schemes, since
# the flavors link different sources and different sdks
# Build both device targets to check Release code and each SDK configuration.
build:
runs-on: macos-26
strategy:
Expand Down
31 changes: 6 additions & 25 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -185,13 +185,7 @@ jobs:
build/*.app.dSYM.zip
if-no-files-found: warn

# Beside the build rather than behind it: it signs nothing and uploads nothing,
# it just drives simulators, and it takes about as long. On a dry run too -
# the artifact is the only way to look at the pictures before the store does.
# A runner per device rather than one doing both. Two simulators on the seven
# gigabytes a runner has spent their time swapping, and the runs took five
# times as long as they do alone; a device to a machine is that, structurally.
# It halves the wall clock as well, since the two halves photograph at once.
# Capture screenshots alongside the build, one runner per device to limit memory use.
screenshots:
if: ${{ inputs.screenshots_from_run == '' }}
runs-on: macos-26
Expand Down Expand Up @@ -221,10 +215,7 @@ jobs:
python-version: "3.13"
- run: python3 -m pip install --quiet Pillow

# The lane builds once into build/screenshots and photographs eighteen
# times without building again. Keeping it means a rerun starts at the
# first simulator instead of at the compiler. Keyed on everything the
# build reads, so a stale one is never used - only ever missed.
# Cache the screenshot build using all source and resource inputs.
- name: reuse the last build
uses: actions/cache@v6
with:
Expand Down Expand Up @@ -268,9 +259,7 @@ jobs:
path: ~/Library/Logs/snapshot
if-no-files-found: warn

# The two halves put back together, and only now checked: a set is both
# devices in every locale, and neither runner above can see the other's.
# Republished under the names the release reads.
# Combine both device artifacts and validate the complete screenshot set.
screenshot-set:
needs: screenshots
runs-on: ubuntu-24.04
Expand Down Expand Up @@ -351,9 +340,7 @@ jobs:
ASC_KEY_CONTENT: ${{ secrets.ASC_KEY_CONTENT }}
run: bundle exec fastlane ios ${{ matrix.lane }}

# its own job because the listing stays editable until the version is submitted,
# while a build cannot be uploaded twice. No macOS runner: this touches the
# listing, not the app
# Upload editable listing metadata independently of the binary.
listing:
needs: [upload, screenshot-set]
# !cancelled() rather than success(), or naming a run to repair - which
Expand Down Expand Up @@ -389,9 +376,7 @@ jobs:
given: ${{ inputs.version }}
run: scripts/resolve-version.py --input "$given" --dry-run "$dry_run"

# where the lane looks for them, and the same set both apps are given.
# From the run being repaired when one was named, so the pictures are the
# ones already taken rather than a quarter hour of simulators again.
# Use the same screenshots for both apps, from the specified run when repairing a listing.
- name: fetch the screenshots
uses: actions/download-artifact@v8
with:
Expand All @@ -412,11 +397,7 @@ jobs:
set -o pipefail
bundle exec fastlane ios ${{ matrix.lane }} 2>&1 | tee listing.log

# The Fastfile stops deliver from sending a picture twice, so neither of
# these should ever print. They stay because both are silent in the store
# and in this log: deliver ends "Successfully uploaded all screenshots"
# and the job goes green either way. If a fastlane upgrade moves what the
# Fastfile patches, this is what still notices.
# Fail if deliver reports duplicate uploads or a truncated screenshot set.
- name: the screenshots went up once each
run: |
set -euo pipefail
Expand Down
4 changes: 1 addition & 3 deletions Ads/AdSlot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -108,9 +108,7 @@ final class AdSlot: NSObject, BannerViewDelegate {
bannerView.load(Request())
}

/// Asks again for a hidden banner, which the unit will not refresh. Reschedules itself before
/// it asks: a request that never comes back must not end the chain. A suspended app cannot
/// fire the work item, so backgrounding needs no guard.
/// Retry unfilled banners, scheduling the next attempt before sending this request.
private func scheduleRetry() {
retry?.cancel()

Expand Down
38 changes: 7 additions & 31 deletions Ads/ConsentManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,25 +8,15 @@ 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.
/// Gathers Lite ad consent through UMP using AdMob region settings.
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.
/// Refresh consent and present a form when required.
/// Reports canRequestAds on main; cached consent survives refresh failures.
func gatherConsent(from viewController: UIViewController, completion: @escaping (Bool) -> Void) {
requestUpdate { updated in
guard updated else {
Expand All @@ -44,13 +34,7 @@ final class ConsentManager {
}
}

/// 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.
/// Refresh the session consent cache without presenting a form. Completes on main.
func refresh(completion: @escaping () -> Void) {
requestUpdate { _ in
DispatchQueue.main.async {
Expand All @@ -59,13 +43,8 @@ final class ConsentManager {
}
}

/// 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.
/// Whether TCF purpose 1 (device storage) allows the advertising identifier.
/// True when no TCF consent is stored.
var adsMayUseAdvertisingIdentifier: Bool {
guard let purposeConsents = UserDefaults.standard.string(forKey: "IABTCF_PurposeConsents") else {
return true
Expand All @@ -80,10 +59,7 @@ final class ConsentManager {
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.
/// Present privacy choices on user request. Completes on main.
func presentPrivacyOptions(from viewController: UIViewController, completion: @escaping () -> Void) {
ConsentForm.presentPrivacyOptionsForm(from: viewController) { formError in
if let formError = formError {
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Rules:

### Changed

- Shortened code comments and corrected editing and screenshot documentation.
- The engine is odrcore 7.4.0, up from 7.3.2.
- A document no longer loads images from the internet.

Expand Down
6 changes: 0 additions & 6 deletions OpenDocumentReader/AppDelegate.swift
Original file line number Diff line number Diff line change
@@ -1,10 +1,4 @@
//
// AppDelegate.swift
// OpenDocument Reader
//
// Created by Thomas Taschauer on 06.02.19.
// Copyright © 2019 Thomas Taschauer. All rights reserved.
//

import UIKit

Expand Down
6 changes: 0 additions & 6 deletions OpenDocumentReader/Constants.swift
Original file line number Diff line number Diff line change
@@ -1,10 +1,4 @@
//
// Constants.swift
// OpenDocumentReader
//
// Created by Artsem Lemiasheuski on 19.12.19.
// Copyright © 2019 Thomas Taschauer. All rights reserved.
//

import Foundation

Expand Down
23 changes: 5 additions & 18 deletions OpenDocumentReader/CoreWrapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,7 @@ private final class PageServer {

var port: UInt32 { lock.withLock { handle?.port ?? 0 } }

/// Connects `service` and returns the base URL its views are served under.
/// Nil if the socket could not be opened.
///
/// A fresh prefix every time, because the web view caches by URL:
/// re-translating after a password or an edit has to end up at an address it
/// has not seen.
/// Serve the translation under a fresh URL to bypass WebKit caches; nil on failure.
func connect(_ service: HtmlService) -> URL? {
lock.lock()
defer { lock.unlock() }
Expand Down Expand Up @@ -65,9 +60,7 @@ private final class PageServer {
/// slide of a presentation, every page of a PDF, the entire text document.
private func isCombinedView(_ view: HtmlView) -> Bool { view.name == "document" }

/// The views to show as pages, the same way OpenDocument.droid picks them: a tab
/// per sheet for spreadsheets, and for everything else the combined view alone,
/// which already holds every slide or page.
/// Use one tab per spreadsheet sheet; prefer the combined view for other documents.
private func selectViews(_ views: [HtmlView], _ documentType: DocumentType) -> [HtmlView] {
let isSpreadsheet = documentType == .spreadsheet
let hasCombinedView = views.contains(where: isCombinedView)
Expand Down Expand Up @@ -152,9 +145,7 @@ private func selectViews(_ views: [HtmlView], _ documentType: DocumentType) -> [
throw coreWrapperError(.unsupportedFileType, "odrcore does not render this file type")
}

// the same answers OpenDocument.droid gives odrcore, so a document is
// the same document on both — the viewport meta each page carries is
// decided from these
// Keep rendering settings consistent with OpenDocument.droid.
let config = HtmlConfig()
config.editable = editable
// how far an edit may reach: inside one paragraph, or across the
Expand All @@ -163,13 +154,9 @@ private func selectViews(_ views: [HtmlView], _ documentType: DocumentType) -> [
// resource paths are resolved relative to an output directory, and in
// server mode there is none — odrcore rejects the combination
config.relativeResourcePaths = false
// the side margins of a printed page, which is what it was written to
// look like, and what makes odrcore call a text document paged: its
// pages are then fitted to the screen rather than shown at full size
// Keep page margins and enable paged layout.
config.textDocumentMargin = true
// the reader's own appearance: odrcore writes a dark sheet behind a
// `prefers-color-scheme: dark`, which the web view answers from the
// system setting. A pdf has no dark view and stays light.
// Follow system appearance; PDFs remain light.
config.colorScheme = .system
// served with the pages rather than inlined as base64
config.embedImages = false
Expand Down
9 changes: 2 additions & 7 deletions OpenDocumentReader/Document.swift
Original file line number Diff line number Diff line change
Expand Up @@ -148,9 +148,7 @@ class Document: UIDocument {
notify { $0.documentLoadingCompleted(self) }
}

/// Clamped to what the document has: switching to page five of a
/// spreadsheet and then editing it into four sheets should not walk off the
/// end.
/// Clamp the selected page after a reload changes the page count.
private func showPage() {
guard let pageURLs, !pageURLs.isEmpty else { return }

Expand All @@ -165,10 +163,7 @@ class Document: UIDocument {
notify { $0.documentUpdateContent(self) }
}

/// UIDocument reads on a background queue, so `load(fromContents:)` — and
/// with it everything `parse` reports — arrives off the main thread. Runs
/// inline when already there, so setting `page` or `edit` still updates the
/// view before returning.
/// Deliver UI callbacks on main, synchronously when already there.
private func notify(_ body: @escaping (DocumentDelegate) -> Void) {
guard !Thread.isMainThread else {
if let delegate {
Expand Down
13 changes: 3 additions & 10 deletions OpenDocumentReader/DocumentBrowserViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,18 +33,14 @@ class DocumentBrowserViewController: UIDocumentBrowserViewController, UIDocument
refreshPrivacyButton()
}

/// Whether the screen `-ODRScreenshot` asked for is already up.
/// `viewDidAppear` runs again every time something over the browser goes
/// away, and a second document presented over the first would be the picture.
/// Stage each screenshot once, even if the browser reappears.
private var isShowingScreenshot = false

private func showForScreenshot(_ screen: ScreenshotMode.Screen) {
guard !isShowingScreenshot else { return }
isShowingScreenshot = true

// The browser is the picture. It opens on Recents, which is empty when
// the samples were laid out rather than opened, so each is revealed in
// turn to put it there.
// Reveal the samples to populate Recents before capturing the browser.
guard screen != .browser else {
reveal(ScreenshotMode.browserDocuments) { [weak self] in
guard let self else { return }
Expand Down Expand Up @@ -78,10 +74,7 @@ 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.
/// Refresh consent and expose privacy options in the browser toolbar.
private func refreshPrivacyButton() {
guard Features.withAds else { return }

Expand Down
Loading
Loading