From 35e717e9fd98637216f6a9c7f105eb2e084caa70 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 13:09:22 +0000 Subject: [PATCH] Take the store screenshots during the release, and upload them The store copy is written and committed; the pictures beside it were made by hand, and the eleven storefronts shared one English set of them. A screenshot is not written, it is taken: it is worth what the build it came off is worth, and a picture of 1.38 sitting in git through 1.41 is a picture of an app nobody can install. So they are taken during the release run, from the build going out, and handed to deliver from there. The run archives the set it took, on a dry run too, which is how to look at them before the store does. `fastlane ios screenshots` drives the app on a 6.9" iPhone and a 13" iPad in the nine store locales the app is translated into; hi and sv are given the English set, because the app has no UI in either. Four pictures each: the onboarding pages, a text document, a spreadsheet with its sheet tabs, and a presentation. Nothing is tapped to get there. The document browser is Apple's UI in eleven languages, so the app takes `-ODRScreenshot ` in Debug builds and puts itself on that screen instead - one launch per picture, each waiting on the view saying it has something to show rather than on a number of seconds. The documents are localized too, which is most of what a reader has to show, and small enough to be worth generating: `scripts/make-screenshot-documents.py` writes twenty-seven packages of about 1.7 KB, and `EXCLUDED_SOURCE_FILE_NAMES` keeps them out of the archive that ships. `scripts/store-screenshots.py` owns what a full set is - which locales, which screens, and the sizes App Store Connect accepts, read off each PNG rather than off the simulator's name, since that changes with every Xcode. The `listing` job stages the set for both apps: they are one app built twice, and the banner Lite carries is not in a screenshot either way. A listing upload with nothing captured still writes the text, so fixing a word does not cost a quarter hour of simulators. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YANh5F8FnQHJUrPL2TiFn4 --- .github/workflows/build_test.yml | 10 + .github/workflows/release.yml | 47 ++- .gitignore | 1 + CHANGELOG.md | 5 + OpenDocumentReader.xcodeproj/project.pbxproj | 117 ++++++ .../xcschemes/ODR Screenshots.xcscheme | 76 ++++ OpenDocumentReader/AppDelegate.swift | 4 + .../ContentViewController.swift | 8 + .../DocumentBrowserViewController.swift | 44 +- .../DocumentViewController.swift | 18 +- .../Samples/sample-sheet-de.ods | Bin 0 -> 1703 bytes .../Samples/sample-sheet-en.ods | Bin 0 -> 1692 bytes .../Samples/sample-sheet-es.ods | Bin 0 -> 1690 bytes .../Samples/sample-sheet-fr.ods | Bin 0 -> 1710 bytes .../Samples/sample-sheet-it.ods | Bin 0 -> 1690 bytes .../Samples/sample-sheet-pl.ods | Bin 0 -> 1718 bytes .../Samples/sample-sheet-pt-BR.ods | Bin 0 -> 1698 bytes .../Samples/sample-sheet-ru.ods | Bin 0 -> 1767 bytes .../Samples/sample-sheet-tr.ods | Bin 0 -> 1714 bytes .../Samples/sample-slides-de.odp | Bin 0 -> 1563 bytes .../Samples/sample-slides-en.odp | Bin 0 -> 1551 bytes .../Samples/sample-slides-es.odp | Bin 0 -> 1563 bytes .../Samples/sample-slides-fr.odp | Bin 0 -> 1568 bytes .../Samples/sample-slides-it.odp | Bin 0 -> 1552 bytes .../Samples/sample-slides-pl.odp | Bin 0 -> 1570 bytes .../Samples/sample-slides-pt-BR.odp | Bin 0 -> 1564 bytes .../Samples/sample-slides-ru.odp | Bin 0 -> 1624 bytes .../Samples/sample-slides-tr.odp | Bin 0 -> 1565 bytes OpenDocumentReader/Samples/sample-text-de.odt | Bin 0 -> 1575 bytes OpenDocumentReader/Samples/sample-text-en.odt | Bin 0 -> 1537 bytes OpenDocumentReader/Samples/sample-text-es.odt | Bin 0 -> 1579 bytes OpenDocumentReader/Samples/sample-text-fr.odt | Bin 0 -> 1585 bytes OpenDocumentReader/Samples/sample-text-it.odt | Bin 0 -> 1567 bytes OpenDocumentReader/Samples/sample-text-pl.odt | Bin 0 -> 1601 bytes .../Samples/sample-text-pt-BR.odt | Bin 0 -> 1565 bytes OpenDocumentReader/Samples/sample-text-ru.odt | Bin 0 -> 1673 bytes OpenDocumentReader/Samples/sample-text-tr.odt | Bin 0 -> 1575 bytes OpenDocumentReader/ScreenshotMode.swift | 112 +++++ .../ScreenshotTests.swift | 48 +++ .../SnapshotHelper.swift | 320 ++++++++++++++ README.md | 36 +- fastlane/Fastfile | 88 +++- fastlane/README.md | 8 + fastlane/screenshots/README.md | 58 +++ fastlane/screenshots/README.txt | 7 - scripts/format.sh | 8 +- scripts/make-screenshot-documents.py | 393 ++++++++++++++++++ scripts/store-screenshots.py | 237 +++++++++++ 48 files changed, 1625 insertions(+), 20 deletions(-) create mode 100644 OpenDocumentReader.xcodeproj/xcshareddata/xcschemes/ODR Screenshots.xcscheme create mode 100644 OpenDocumentReader/Samples/sample-sheet-de.ods create mode 100644 OpenDocumentReader/Samples/sample-sheet-en.ods create mode 100644 OpenDocumentReader/Samples/sample-sheet-es.ods create mode 100644 OpenDocumentReader/Samples/sample-sheet-fr.ods create mode 100644 OpenDocumentReader/Samples/sample-sheet-it.ods create mode 100644 OpenDocumentReader/Samples/sample-sheet-pl.ods create mode 100644 OpenDocumentReader/Samples/sample-sheet-pt-BR.ods create mode 100644 OpenDocumentReader/Samples/sample-sheet-ru.ods create mode 100644 OpenDocumentReader/Samples/sample-sheet-tr.ods create mode 100644 OpenDocumentReader/Samples/sample-slides-de.odp create mode 100644 OpenDocumentReader/Samples/sample-slides-en.odp create mode 100644 OpenDocumentReader/Samples/sample-slides-es.odp create mode 100644 OpenDocumentReader/Samples/sample-slides-fr.odp create mode 100644 OpenDocumentReader/Samples/sample-slides-it.odp create mode 100644 OpenDocumentReader/Samples/sample-slides-pl.odp create mode 100644 OpenDocumentReader/Samples/sample-slides-pt-BR.odp create mode 100644 OpenDocumentReader/Samples/sample-slides-ru.odp create mode 100644 OpenDocumentReader/Samples/sample-slides-tr.odp create mode 100644 OpenDocumentReader/Samples/sample-text-de.odt create mode 100644 OpenDocumentReader/Samples/sample-text-en.odt create mode 100644 OpenDocumentReader/Samples/sample-text-es.odt create mode 100644 OpenDocumentReader/Samples/sample-text-fr.odt create mode 100644 OpenDocumentReader/Samples/sample-text-it.odt create mode 100644 OpenDocumentReader/Samples/sample-text-pl.odt create mode 100644 OpenDocumentReader/Samples/sample-text-pt-BR.odt create mode 100644 OpenDocumentReader/Samples/sample-text-ru.odt create mode 100644 OpenDocumentReader/Samples/sample-text-tr.odt create mode 100644 OpenDocumentReader/ScreenshotMode.swift create mode 100644 OpenDocumentReaderUITests/ScreenshotTests.swift create mode 100644 OpenDocumentReaderUITests/SnapshotHelper.swift create mode 100644 fastlane/screenshots/README.md delete mode 100644 fastlane/screenshots/README.txt create mode 100755 scripts/make-screenshot-documents.py create mode 100755 scripts/store-screenshots.py diff --git a/.github/workflows/build_test.yml b/.github/workflows/build_test.yml index 36f3ea5..d7a327f 100644 --- a/.github/workflows/build_test.yml +++ b/.github/workflows/build_test.yml @@ -45,6 +45,16 @@ 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 + - name: build the screenshot tests + run: > + xcodebuild build-for-testing + -project OpenDocumentReader.xcodeproj + -scheme "ODR Screenshots" + -destination 'generic/platform=iOS Simulator' + - uses: actions/upload-artifact@v7 if: always() with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e6ee562..6c82b55 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -181,6 +181,44 @@ 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. + screenshots: + runs-on: macos-26 + steps: + - name: checkout + uses: actions/checkout@v7 + + - uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: ${{ env.xcode_version }} + + # the lane checks the set it produced, so a language that came out short + # fails here rather than half way up to App Store Connect + - name: photograph both devices in every locale + run: bundle exec fastlane ios screenshots + + - name: archive the screenshots + uses: actions/upload-artifact@v7 + with: + name: screenshots + path: fastlane/screenshots + if-no-files-found: error + # png, so there is nothing left to squeeze out of them + compression-level: 0 + + - uses: actions/upload-artifact@v7 + if: failure() + with: + name: screenshot-log + path: ~/Library/Logs/snapshot + if-no-files-found: warn + # a job per app, fail-fast off, so "Re-run failed jobs" can retry one half upload: needs: build @@ -219,7 +257,7 @@ jobs: # while a build cannot be uploaded twice. No macOS runner: this touches the # listing, not the app listing: - needs: upload + needs: [upload, screenshots] if: ${{ !inputs.dry_run }} runs-on: ubuntu-24.04 strategy: @@ -244,6 +282,13 @@ jobs: given: ${{ inputs.version }} run: .github/scripts/resolve-version.py --input "$given" --dry-run "$dry_run" + # where the lane looks for them, and the same set both apps are given + - name: fetch the screenshots + uses: actions/download-artifact@v8 + with: + name: screenshots + path: fastlane/screenshots + - name: write ${{ matrix.app }}'s listing env: ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} diff --git a/.gitignore b/.gitignore index 08373d5..4345998 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,7 @@ Carthage/Build fastlane/report.xml fastlane/Preview.html fastlane/screenshots/**/*.png +fastlane/screenshots/screenshots.html fastlane/test_output .DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index a7aaf0a..07bab5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,11 @@ once the version tag exists. the bar ends the edit and shows what was written; a save that failed stays in it. Saving has left the menu; discarding is still there and now leaves edit mode. +- The App Store screenshots are taken by the release run, from the build going + out, instead of being made by hand: four per device on a 6.9" iPhone and a + 13" iPad, in every locale the app is translated into, of documents written + for the purpose in that language. `-ODRScreenshot` puts a Debug build on one + screen, so nothing has to be tapped to get there. ### Fixed diff --git a/OpenDocumentReader.xcodeproj/project.pbxproj b/OpenDocumentReader.xcodeproj/project.pbxproj index 650610e..17b5bb8 100644 --- a/OpenDocumentReader.xcodeproj/project.pbxproj +++ b/OpenDocumentReader.xcodeproj/project.pbxproj @@ -29,6 +29,13 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + A11E00000000000000000037 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = E2F7ED47220B54D600D63515 /* Project object */; + proxyType = 1; + remoteGlobalIDString = E2F7ED4E220B54D600D63515; + remoteInfo = OpenDocumentReader; + }; E22B25312557F0E2001D0C52 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = E2F7ED47220B54D600D63515 /* Project object */; @@ -49,6 +56,7 @@ A11E00000000000000000013 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; A11E00000000000000000014 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; A11E00000000000000000015 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; + A11E00000000000000000038 /* OpenDocumentReaderUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OpenDocumentReaderUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; E1EB6C492C1A510D003EC5A0 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = System/Library/Frameworks/Foundation.framework; sourceTree = SDKROOT; }; E237952F2274844400BA7238 /* AdSupport.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AdSupport.framework; path = System/Library/Frameworks/AdSupport.framework; sourceTree = SDKROOT; }; E26C39382250DC6E009C484A /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; }; @@ -77,9 +85,21 @@ path = OpenDocumentReaderTests; sourceTree = ""; }; + A11E00000000000000000030 /* OpenDocumentReaderUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = OpenDocumentReaderUITests; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ + A11E00000000000000000034 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; A11E00000000000000000003 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -171,10 +191,12 @@ A11E00000000000000000009 /* Ads */, A11E0000000000000000000A /* NoAds */, A11E00000000000000000022 /* OpenDocumentReaderTests */, + A11E00000000000000000030 /* OpenDocumentReaderUITests */, E2BB4B64220EF4F20056176B /* Frameworks */, 52A348B12A9262D100DACAB9 /* OpenDocumentReader.app */, A11E00000000000000000008 /* OpenDocumentReader Lite.app */, 52A348B22A9262D100DACAB9 /* OpenDocumentReaderTests.xctest */, + A11E00000000000000000038 /* OpenDocumentReaderUITests.xctest */, ); sourceTree = ""; }; @@ -228,6 +250,27 @@ productReference = 52A348B22A9262D100DACAB9 /* OpenDocumentReaderTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; + A11E00000000000000000031 /* OpenDocumentReaderUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = A11E00000000000000000032 /* Build configuration list for PBXNativeTarget "OpenDocumentReaderUITests" */; + buildPhases = ( + A11E00000000000000000033 /* Sources */, + A11E00000000000000000034 /* Frameworks */, + A11E00000000000000000035 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + A11E00000000000000000036 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + A11E00000000000000000030 /* OpenDocumentReaderUITests */, + ); + name = OpenDocumentReaderUITests; + productName = OpenDocumentReaderUITests; + productReference = A11E00000000000000000038 /* OpenDocumentReaderUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; E2F7ED4E220B54D600D63515 /* OpenDocumentReader */ = { isa = PBXNativeTarget; buildConfigurationList = E2F7ED65220B54D700D63515 /* Build configuration list for PBXNativeTarget "OpenDocumentReader" */; @@ -266,6 +309,10 @@ A11E00000000000000000001 = { CreatedOnToolsVersion = 26.0; }; + A11E00000000000000000031 = { + CreatedOnToolsVersion = 26.0; + TestTargetID = E2F7ED4E220B54D600D63515; + }; E22B252B2557F0E2001D0C52 = { CreatedOnToolsVersion = 12.0; TestTargetID = E2F7ED4E220B54D600D63515; @@ -313,6 +360,7 @@ E2F7ED4E220B54D600D63515 /* OpenDocumentReader */, A11E00000000000000000001 /* OpenDocumentReader Lite */, E22B252B2557F0E2001D0C52 /* OpenDocumentReaderTests */, + A11E00000000000000000031 /* OpenDocumentReaderUITests */, ); }; /* End PBXProject section */ @@ -333,6 +381,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A11E00000000000000000035 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; E2F7ED4D220B54D600D63515 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -358,6 +413,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A11E00000000000000000033 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; E2F7ED4B220B54D600D63515 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -368,6 +430,11 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + A11E00000000000000000036 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = E2F7ED4E220B54D600D63515 /* OpenDocumentReader */; + targetProxy = A11E00000000000000000037 /* PBXContainerItemProxy */; + }; E22B25322557F0E2001D0C52 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = E2F7ED4E220B54D600D63515 /* OpenDocumentReader */; @@ -423,6 +490,7 @@ ENABLE_BITCODE = NO; ENABLE_MODULE_VERIFIER = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; + EXCLUDED_SOURCE_FILE_NAMES = "sample-*"; INFOPLIST_FILE = configs/lite/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "OpenDocument Reader"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; @@ -441,6 +509,45 @@ }; name = Release; }; + A11E00000000000000000039 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_STYLE = Automatic; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = app.opendocument.OpenDocumentReaderUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = OpenDocumentReader; + }; + name = Debug; + }; + A11E0000000000000000003A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_STYLE = Automatic; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = app.opendocument.OpenDocumentReaderUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = OpenDocumentReader; + }; + name = Release; + }; E22B25342557F0E2001D0C52 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -648,6 +755,7 @@ ENABLE_BITCODE = NO; ENABLE_MODULE_VERIFIER = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; + EXCLUDED_SOURCE_FILE_NAMES = "sample-*"; INFOPLIST_FILE = configs/full/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "OpenDocument Reader"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; @@ -669,6 +777,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + A11E00000000000000000032 /* Build configuration list for PBXNativeTarget "OpenDocumentReaderUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A11E00000000000000000039 /* Debug */, + A11E0000000000000000003A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; A11E00000000000000000005 /* Build configuration list for PBXNativeTarget "OpenDocumentReader Lite" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/OpenDocumentReader.xcodeproj/xcshareddata/xcschemes/ODR Screenshots.xcscheme b/OpenDocumentReader.xcodeproj/xcshareddata/xcschemes/ODR Screenshots.xcscheme new file mode 100644 index 0000000..37c652d --- /dev/null +++ b/OpenDocumentReader.xcodeproj/xcshareddata/xcschemes/ODR Screenshots.xcscheme @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/OpenDocumentReader/AppDelegate.swift b/OpenDocumentReader/AppDelegate.swift index b3104c7..8e1cb26 100644 --- a/OpenDocumentReader/AppDelegate.swift +++ b/OpenDocumentReader/AppDelegate.swift @@ -15,6 +15,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate { _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { + // does nothing unless this launch was asked for a screenshot, and has to + // be ahead of anything that reads the defaults it settles + ScreenshotMode.prepare() + StoreReviewHelper.incrementAppOpenedCount() return true diff --git a/OpenDocumentReader/ContentViewController.swift b/OpenDocumentReader/ContentViewController.swift index f961c98..efe7cee 100644 --- a/OpenDocumentReader/ContentViewController.swift +++ b/OpenDocumentReader/ContentViewController.swift @@ -55,6 +55,14 @@ class ContentViewController: UIViewController { pageControl.numberOfPages = Constants.onboardingImages.count } + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + // after the presentation animation, so a screenshot of the intro is of + // the page rather than of it sliding in + ScreenshotMode.markReady(view) + } + @IBAction func pageButtonPressed(_ sender: Any) { guard !isLastPage else { dismissViewController() diff --git a/OpenDocumentReader/DocumentBrowserViewController.swift b/OpenDocumentReader/DocumentBrowserViewController.swift index b97c9b2..98db9ea 100644 --- a/OpenDocumentReader/DocumentBrowserViewController.swift +++ b/OpenDocumentReader/DocumentBrowserViewController.swift @@ -22,6 +22,14 @@ class DocumentBrowserViewController: UIDocumentBrowserViewController, UIDocument override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) + // before anything that depends on how often the app has been opened: + // a screenshot run wants one screen and nothing over it + if let screen = ScreenshotMode.screen { + showForScreenshot(screen) + + return + } + StoreReviewHelper.checkAndAskForReview() // ahead of the intro guard below, which most launches return at @@ -32,10 +40,40 @@ class DocumentBrowserViewController: UIDocumentBrowserViewController, UIDocument guard !wasIntroWatched else { return } - if let pageVC = storyboard?.instantiateViewController(withIdentifier: pageViewController) as? PageViewController - { - present(pageVC, animated: true, completion: nil) + presentIntro() + } + + private func presentIntro() { + guard + let pageVC = storyboard?.instantiateViewController(withIdentifier: pageViewController) + as? PageViewController + else { + return } + + present(pageVC, animated: true, completion: nil) + } + + /// 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. + private var isShowingScreenshot = false + + private func showForScreenshot(_ screen: ScreenshotMode.Screen) { + guard !isShowingScreenshot else { return } + isShowingScreenshot = true + + guard screen != .intro else { + presentIntro() + + return + } + + // nothing on a missing sample, so the test fails waiting for a document + // rather than photographing the browser + guard let document = ScreenshotMode.document(for: screen) else { return } + + presentDocument(at: document) } // MARK: - Privacy diff --git a/OpenDocumentReader/DocumentViewController.swift b/OpenDocumentReader/DocumentViewController.swift index 6aefc72..29e6f9e 100644 --- a/OpenDocumentReader/DocumentViewController.swift +++ b/OpenDocumentReader/DocumentViewController.swift @@ -17,6 +17,9 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel private var browserTransition: DocumentBrowserTransitioningDelegate? private var hasStartedAds = false + /// The navigation that is putting a document on screen, as opposed to the + /// "loading" or the error page. Nothing but a screenshot asks. + private var documentNavigation: WKNavigation? public var transitionController: UIDocumentBrowserTransitionController? { didSet { if let controller = transitionController { @@ -232,6 +235,13 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { updateSearchButton() + + // the document is drawn, which is what a screenshot of it waits for - + // and only the document: the "loading" page finishes first, and a + // picture of it is a picture of the word "loading" + if let documentNavigation, navigation === documentNavigation { + ScreenshotMode.markReady(view) + } } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { @@ -683,6 +693,7 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel func documentUpdateContent(_ doc: Document) { guard let url = doc.result else { + documentNavigation = nil self.webview.loadHTMLString( "

\(NSLocalizedString("loading", comment: ""))

", baseURL: nil) @@ -694,9 +705,9 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel // pages come off the loopback server; a file URL needs read access // granted along with it if url.isFileURL { - self.webview.loadFileURL(url, allowingReadAccessTo: url) + documentNavigation = self.webview.loadFileURL(url, allowingReadAccessTo: url) } else { - self.webview.load(URLRequest(url: url)) + documentNavigation = self.webview.load(URLRequest(url: url)) } } @@ -742,7 +753,7 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel let fileName = doc.fileURL.absoluteString.lowercased() if systemRenderedExtensions.contains(where: fileName.hasSuffix) { // not odrcore's to render, but the web view knows the format - self.webview.loadFileURL(doc.fileURL, allowingReadAccessTo: doc.fileURL) + documentNavigation = self.webview.loadFileURL(doc.fileURL, allowingReadAccessTo: doc.fileURL) canEdit = false canSearch = false @@ -757,6 +768,7 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel return } + documentNavigation = nil self.webview.loadHTMLString( "

\(NSLocalizedString("error", comment: ""))

\(NSLocalizedString("toast_error_generic", comment: ""))", baseURL: nil) diff --git a/OpenDocumentReader/Samples/sample-sheet-de.ods b/OpenDocumentReader/Samples/sample-sheet-de.ods new file mode 100644 index 0000000000000000000000000000000000000000..897ff310cc3e165e37ed8f73184d7360e0832fb1 GIT binary patch literal 1703 zcmWIWW@Zs#fB;2?)*Q<&Js<~!Ie<7fGdH!QvLH3FpdcqRIk6-&KTp3bFGVjuu{g6> zFTWr)FC{;@G&eP`M6bA@C^a#qI3qQ+BminA$OJ`(Nv88+j{#Zd7#SEufwZq{h@-Bj zpPPPeVqRuiYH^8PMQ+a2iH==|4R~C?Te@DbD@+e-?z|l9f5Eb0)v;Q z_oJCFdss#DtvRWdqU-m(>B;8VLX%$Xuc|HHRX&?x{)OenS|0P(7`H9+&UY}qlJ%eNxvt^sBK3Do;(^yc zM#oH3RodV2_R9Y0U#{|_dOSnuuc8ak(>IwJ7`T8QFD|LfNi7EXwm0~6-eCoSJd8VysW=9@u39sI7Yu!_-ox3fH|Fea{2Vfm{=-JgRB&-cz;R>bRlGs~M17O=_Mq;djb1Se z9F3G}%`N}_x$$LQ{l|#6$&2ND_o(qcK7CW9;?&DBW!=4v>ZzxKW4?SV{cH0u?8haG zwgC4Za@I`8^wJBKnw)d6mvlFgZabM4`|WZ1|MT^|$GRF$oiCpG;ob+m>E=sagB4Xj zN7OZ+zVwJ+H1EgZ<9F8AF`$O^#{ak09A#o)I1Eh7+`zC-&d)0WrAlC6zm2$Ebjv`X z_I!B7l=R}lh9>sO+s%w?U#k9PNIdyq<=d|wkG|>uo&7K3`Xogo)lNmj%o+P6-g~*H z=TExUlYc$)kbegP79i##0f$Bz<>&34P&wZnN&G?diW~oz7TtOJ(cBs!hFNHEUlrb?+3_ znR_k$+T>gl;gV4Cm@`Kg-1@-rs_D8>q4I$h%Pm7c&J!)NTrIo%>6`Vgr%EDBA51(s zb+fN!tcFKn-HTTTyk&}xsj>fhIpceise)pO=gN)@DfQV6FBT|fZt~cibXWG4TtRW; zH7?QO$V1+LwbHWWCd93lU$$iJ&f8)?FCY70?RQi8+XofP6y8<2(MlhdoBXj9-f$u! zQM;odrdaN-%aj*^0zKU2TwIGqCity3J~35Uv2$ke*{Yl)*2mt+@{2u?b8e|V8T+xq z?6}c9r$b*(MQ_Tp=(zEq;{JQbi%*W8lm0I9Wc{<-j_$|a9$%ds-4UE1-K(2AliO$g zoZ@vof@#&SmM7oy)v5bGOKhv`xv7P_j5g~W5L)1Qw>>IvKYv5R)Qj)dHgno;@!F}s zO?ti>_q$cTea=CgHj7WBtT}(O<`Zkmnhzlo+r3%$y%98V{=M_F$|E|CPEAQ8}=d(|EzkJsBe!bqJ$CkUecTU*%;Kl4+&u(u2{bhaK z`S(|zZLU{;_n+Bv=P%{}Z$>5&X50leFv)|#5@30QD7F#0uouM;Jq!#>7(;+etOYZ= s5$J^)!U$iW)rcYvT_1V|Md;fJ%(h6`HNcyd4J63|gs*_~a#j!z0P;K0U;qFB literal 0 HcmV?d00001 diff --git a/OpenDocumentReader/Samples/sample-sheet-en.ods b/OpenDocumentReader/Samples/sample-sheet-en.ods new file mode 100644 index 0000000000000000000000000000000000000000..0b26d91e2be0c2f05cd4335aabb8aea61c82636b GIT binary patch literal 1692 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 literal 0 HcmV?d00001 diff --git a/OpenDocumentReader/Samples/sample-sheet-it.ods b/OpenDocumentReader/Samples/sample-sheet-it.ods new file mode 100644 index 0000000000000000000000000000000000000000..42413d4d027b91a0aede3eb195658df6f4fa64df GIT binary patch literal 1690 zcmWIWW@Zs#fB;2?)*Q<&Js<~!Ie<7fGdH!QvLH3FpdcqRIk6-&KTp3bFGVjuu{g6> zFTWr)FC{;@G&eP`M6bA@C^a#qI3qQ+BminA$OJ`(Nv88+j{#Zd7#SEufwZq{h@-Bj zpPPPeVqRuiYH^8PMQ+a2iH==|4R~C?Te@DbD@+e-?z|l9f5Eb0)v;Q z_oJCFdss#DtvRWdqU-m(>B;8VLX%$Xuc|HHRX&?x{)OenS|0P(7`H9+&UY}qlJ%eNxvt^sBK3Do;(^yc zM#oH3RodV2_R9Y0U#{|_dOSnuuc8ak(>IwJ7`T8QFD|LfNi7EXwm0~6-eCoSJd8VysW=9@u39sI7Yu!_-ox3fH|Fea{2Vfm{=-JgRB&-cz;R>bRlGs~M17O=_Mq;djb1Se z9F3G}%`N}_x$$LQ{l|#6$&2ND_o(qcK7CW9;?&DBW!=4v>ZzxKW4?SV{cH0u?8haG zwgC4Za@I`8^wJBKnw)d6mvlFgZabM4`|WZ1|MT^|$GRF$oiCpG;ob+m>E=sagB4Xj zN7OZ+zVwJ+H1EgZ<9F8AF`$OEp1^zU9ZU=iyMSq#8yMEf`FSOvR0$01w-LU@w+saK zJ`b;$vYFY!p_zTMbGnt~w#z2vi6{G(ZM&|_$?<<*_DO-6la8nuX3mJ~DPOvLd;Fwp zKI`Q_orx}5$s!^s8S|y{c(=q1jY}PWcKv-8Kk@a=nNzGh$Z!UbeNowi!$m|_6-z+k%zmR+I>K(Re=WZL% zKB+g$b5|Dkx@SoNu@y|Y2l=%=3m;hVT=i6lacjjGtK4P zyq4$sYlSOXo$p!N=-`Z-m~J`-9>j9_fN@Kd!=c$ zRNU+ErkB4vc=X*0{i@n_Kc8Tq&$IvG6x&4p^P6g~Ex5H+wXwkO@%@!8t_QWE9o?SH z`TWFa;gO!El$c+|7bKQ;e9`~)IH;P-=-{4WvlQWb+tczZH!ze-2;K4i*VX++?sNs8 zq_KAX3AT?GiH{H0?q~JgY;K)@_j16#+4REvYONGAt?(dm;Me}ySlT8b+s74!<-PC#+f8y2L1({mAzEoE{ z-O=kbJ?8O&hfWJGzmhb`D!s5Mtbf9JX|2fXyT25@sfzQA-Q*jy$#Z*51@ad>+RNyZK=XFfqd)6-rzvl1!yXO8c(J5m8 zqp$g2+_dkG{X8$_@`~(Ip|j@>@}B72!onVZNw5&X53{nFqMPB5?~R7D5(*;u$R6NJq!#>7(;+etYtB}5$NR@!U$iW)re9JT_1X$ cMCjWI%&AB@G{Bpc4J63|gs*_~ELIQ?0H1)v0{{R3 literal 0 HcmV?d00001 diff --git a/OpenDocumentReader/Samples/sample-sheet-pl.ods b/OpenDocumentReader/Samples/sample-sheet-pl.ods new file mode 100644 index 0000000000000000000000000000000000000000..e96ecb38c51e09dad955edaf6398396f3c17a59c GIT binary patch literal 1718 zcmWIWW@Zs#fB;2?)*Q<&Js<~!Ie<7fGdH!QvLH3FpdcqRIk6-&KTp3bFGVjuu{g6> zFTWr)FC{;@G&eP`M6bA@C^a#qI3qQ+BminA$OJ`(Nv88+j{#Zd7#SEufwZq{h@-Bj zpPPPeVqRuiYH^8PMQ+a2iH==|4R~C?Te@DbD@+e-?z|l9f5Eb0)v;Q z_oJCFdss#DtvRWdqU-m(>B;8VLX%$Xuc|HHRX&?x{)OenS|0P(7`H9+&UY}qlJ%eNxvt^sBK3Do;(^yc zM#oH3RodV2_R9Y0U#{|_dOSnuuc8ak(>IwJ7`T8QFD|LfNi7EXwm0~6-eCoSJd8VysW=9@u39sI7Yu!_-ox3fH|Fea{2Vfm{=-JgRB&-cz;R>bRlGs~M17O=_Mq;djb1Se z9F3G}%`N}_x$$LQ{l|#6$&2ND_o(qcK7CW9;?&DBW!=4v>ZzxKW4?SV{cH0u?8haG zwgC4Za@I`8^wJBKnw)d6mvlFgZabM4`|WZ1|MT^|$GRF$oiCpG;ob+m>E=sagB4Xj zN7OZ+zVwJ+H1EgZ<9F8AF`$Mu`(xK#SD1i_449U=fnlATpH~7(mB7G$8*w)1wt>LD z&mwY#W}ZHi)aF^-v6}mG;g+TR3nDfZYFFD%`c(bi`HN-M+qEC!rmCu+$YfwI(D2dk zE8Ds{|G!GEiB7%vQ4(X2<3%Udf1B&iiNBGEN!GZ}4cD|8NRde4E`9lj!W9+qSIV$m4!Ob@keHt+~0L;+xhL zDMnvBI{DX$s2kVRWX`|V65ph?Vp|nU=~VHioq{V?EVrC?a++w8*!8}ETZs&pd4?aYn zNt`wDP}l=Tt7Rruy#|*yAK-lYj%7(#P{S?tw{?25j5>31xBA9;r!6HahhzlZI0%ioSK-@pH5Qc3GCySGDKvEJx@nUzu2-MHM>PIDL{v+hn3 zvkZSJI(>>)a?8zmzh2MS*m=}4{^E?-TJ>YkyAmSnm+m=MlJB+itC5Js8Q$`2*{SDa zS5LW}wD-N??4WSHVpCO>aCZ@@{x60z`_CtC<=OuzaP^X_3E6+s3hc}H_+QG(d=iek z_$BU&R{i8xw^#!XPvFlwe@te%!SP5Zd7*Y$1wm$=)rA4&-PiN(KM)I9oN;$v@3IwY z7x(Pz4GZ}1ly|=I!+rbp^`^6~y*uzJM$k9ut1Y*;V6Rxf@}PBdrJhzbOsPBf^VO^N zoJ$`s?|WO}XxeGEw4W`Gkpf>qThx^HDA~RycwB9m~ofez#ISuOMt}>q7+By!d@~% z^e`|iVGIE>v6j>5Mxd8*2qS!fRwGJ0bbaXg6`^k@Fc%}`-T-e_HjpF>5WWJ^+gL$7 E0PQ8k(*OVf literal 0 HcmV?d00001 diff --git a/OpenDocumentReader/Samples/sample-sheet-pt-BR.ods b/OpenDocumentReader/Samples/sample-sheet-pt-BR.ods new file mode 100644 index 0000000000000000000000000000000000000000..0bc55743520aea28994a7bcab6a188270ffa9104 GIT binary patch literal 1698 zcmWIWW@Zs#fB;2?)*Q<&Js<~!Ie<7fGdH!QvLH3FpdcqRIk6-&KTp3bFGVjuu{g6> zFTWr)FC{;@G&eP`M6bA@C^a#qI3qQ+BminA$OJ`(Nv88+j{#Zd7#SEufwZq{h@-Bj zpPPPeVqRuiYH^8PMQ+a2iH==|4R~C?Te@DbD@+e-?z|l9f5Eb0)v;Q z_oJCFdss#DtvRWdqU-m(>B;8VLX%$Xuc|HHRX&?x{)OenS|0P(7`H9+&UY}qlJ%eNxvt^sBK3Do;(^yc zM#oH3RodV2_R9Y0U#{|_dOSnuuc8ak(>IwJ7`T8QFD|LfNi7EXwm0~6-eCoSJd8VysW=9@u39sI7Yu!_-ox3fH|Fea{2Vfm{=-JgRB&-cz;R>bRlGs~M17O=_Mq;djb1Se z9F3G}%`N}_x$$LQ{l|#6$&2ND_o(qcK7CW9;?&DBW!=4v>ZzxKW4?SV{cH0u?8haG zwgC4Za@I`8^wJBKnw)d6mvlFgZabM4`|WZ1|MT^|$GRF$oiCpG;ob+m>E=sagB4Xj zN7OZ+zVwJ+H1EgZ<9F8AF`$O^qXi##9$;c%*b7X{+`zC-&d)0WrAlC6zl}IsG}}O6 z-)~Kcr#-!$Z*TP!^38p@~0?eB(8L z-`~kwD(W`-CT08X>`?f6al?Cs?q!SuTP8Pe!7f>6IYIrF-E;;RDqiB-sw!XVof3hcP$-43G$hf%q zo96MIEiPyIb7U(Pwymk0$^3WAsqal!-5GfTmnK+rKAPKKazP|>j(Xo>&ZYzDGB`UmS{_t2n(bUXg zeYaTK>!#^pkA5cgDYv~RXnENc{W_;Id0K;o(lt@ZiISyWo3rIY8`yb#-l|qRFD_Vm zs)Ez-qW1ffZ3kN>o_x9Qe&dmktLN(9ojhUh%zSs*nf!9=x78}@HNG*~Hp%r^Xw}iK zph&5}Jb#eWjl{lB{PCB2&4i+uop)zMPfcLGxaFI(DZd?C!{KFDs>+#~mHuYC zU0-q5>DXm$El*xeaq8Y_r@8rPxaZ`b3xuwmKKSVFggWVY^Do(2FTJ<${3f%nrSqcG z_qQtEZ#>ES?2D?P#U`6>#?xsmSJpnVI>(%>SbEA#YIcs-t4U|)&d6!~qM!2B?vSFd z>-4_&bg*L-HQ24 zlI(oN<;U~dSLs%7e*N(37WZb;va@qzJ>I8uSpQCuTkBP&?!0VWtld@k zcMdn_qx#do6r-*^|9#y14vTkJ#QlP?F8mnq?{Vy&B_LnWC6lgKzb1?hz9`eqsUeO literal 0 HcmV?d00001 diff --git a/OpenDocumentReader/Samples/sample-sheet-ru.ods b/OpenDocumentReader/Samples/sample-sheet-ru.ods new file mode 100644 index 0000000000000000000000000000000000000000..df665f81062219ea2691201c7bad743e83305b40 GIT binary patch literal 1767 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 literal 0 HcmV?d00001 diff --git a/OpenDocumentReader/Samples/sample-slides-en.odp b/OpenDocumentReader/Samples/sample-slides-en.odp new file mode 100644 index 0000000000000000000000000000000000000000..8f8050e1b461615c23abc6dfc7ca6126cb164157 GIT binary patch literal 1551 zcmWIWW@Zs#fB;1XW3@Fa^nn}@<^bZ{%-qzH%7WCyf`XjP8sezy>F1`O zo0ylGmRek*SCN}Db)sX}VFMo5@0P9?>^^Q;eelT3b?Pr<4p^M(IXKOC$&_c`9<$v_ z(`bF^CG@N6zjS%KH_ul|^>3X#Z*K9gm>45vQ!D+$N_Mut#g|ZV-JgPyU)HWXE&H+7 z#K3>a1y85p>L9xpDb%t;XJxlNe7Y9RK;dNnUef^vUZtXWo=8^VDx%<9I$wdCRGUqKd`_ z%~jkBPw~{%?ptWQ=SkoyOYtcWoi6(srB2-VeU{{dBc~p$oN;hnXoRpbPne5Qv+mJd ziCKaVC-fc<6E>Rlc545VfZUr2-@9zTdwTEtUM_cX;jWC6x$AEf-<{o7wC;Nsd$964 z!K<4T!`3V_sNMPd?XRL_qxH%bjml?~H{{ermB=V8*&JoXZYAHS-B@zV%h{_ftLXY% zR^dm>Pd+$a{mAzJqlL>#o41AS$bM9{>W)j|Y96`sA&1x_3wPKN~J9_v01+(~JD5fy&{;o#M>I zz#z-Xz`zX*)a3lU5>WaBhU(QY@4VXv0(YN_uJTF^QSQb+mat*1U-eJZ59Y=z#A zRYiYNe;Zcz=bMSo)bsX>jLNv>wJgSIYtaXnhA^|<)s}g^UZxxGEbH<}3w95_YjAb( zrNT0)Z(V|~{gTXVRQfOW$=;3GaK5%db@xx}_9uqB44xQ>zbSUSVz}o@@j-X(NkPr8 zHYxV`@7nM?Yg^gxSAi4XS-8nXJUq+#s(;QFKkpOEJq7L^5<2xrvZY?+c-?iG``mFG zbjzC_>ct72JgRw|*Kmo^l9jb@7akJum12G?b$q7lQa@Eg(eRUg);Da9dux46>iK-! zqH)TD6GuM1zi{;Hs=|s6S&8*o1}FTwm0VVe-FoultZ3}?qgG<)#FV}KgZAXMCcI01 z8N2R(?}Mz$z6F=ugg#DC`ndnI$mXN-{dd^^nXBi=c>ApMVVxbEleTuft%_zo-?h-7 zOX@D?q5>X|Bw6Fm^B#{Lv4>3x+o2H>zQANd%YnHs1#dJ;B-Tjv^6x1yJ#+tCP%AbbU+`B_0c04(HhOaK4? literal 0 HcmV?d00001 diff --git a/OpenDocumentReader/Samples/sample-slides-es.odp b/OpenDocumentReader/Samples/sample-slides-es.odp new file mode 100644 index 0000000000000000000000000000000000000000..63ac2783de67b7cb3db637614135fdf3320c89fe GIT binary patch literal 1563 zcmWIWW@Zs#fB;1XW3@Fa^nn}@<^bZ{%-qzH%7WCyf`XjP8sezy>F1`O zo0ylGmRek*SCN}Db)sX}VFMo5@0P9?>^^Q;eelT3b?Pr<4p^M(IXKOC$&_c`9<$v_ z(`bF^CG@N6zjS%KH_ul|^>3X#Z*K9gm>45vQ!D+$N_Mut#g|ZV-JgPyU)HWXE&H+7 z#K3>a1y85p>L9xpDb%t;XJxlNe7Y9RK;dNnUef^vUZtXWo=8^VDx%<9I$wdCRGUqKd`_ z%~jkBPw~{%?ptWQ=SkoyOYtcWoi6(srB2-VeU{{dBc~p$oN;hnXoRpbPne5Qv+mJd ziCKaVC-fc<6E>Rlc545VfZUr2-@9zTdwTEtUM_cX;jWC6x$AEf-<{o7wC;Nsd$964 z!K<4T!`3V_sNMPd?XRL_qxH%bjml?~H{{ermB=V8*&JoXZYAHS-B@zV%h{_ftLXY% zR^dm>Pd+$a{mAzJqlL>#o41AS$bM9{>W)j|Y96`sA&1x_3wPKN~J9_v01+(~JD5fg0YUBJ9t^ zz+lSBz`zX*)a3lU5>WaBhU(QY@4VXv0(XCluJTF^T!h_S}YL>9<{3D)oZo(k5I}4L9>}jI0rI(H8!7cgD4-)&o5T3zJmM7y3G{ z*kQHkP+{4NbDsp0qb;T1O*)=&TH0Ft&eMAbHl_SKsaM%^&cyl*Yx&NMR}%9M#}p)| zOkmy>7j~x0Q#399%(uMwJ4!Ea8_sGpy{mU(inR&vrB@Mi$ z{i(mcZWdIX7HY^GJgHLddt=^$cQX!b_j&a6_?Cv}rtS5w|MfrH^FONl)lA99*>zmO zZPocjGkFW^U-GWM{NLvGQTYIGMkWzv+=U}B@qxh-U=~Ldl?YwfOFxJn28Jb!p+F|q vvJl+}^l}7YgdfmqM5%(V4?U|O^eqNvAf${F;LXYgl4JqGS3p{t6~qGop*4K` literal 0 HcmV?d00001 diff --git a/OpenDocumentReader/Samples/sample-slides-fr.odp b/OpenDocumentReader/Samples/sample-slides-fr.odp new file mode 100644 index 0000000000000000000000000000000000000000..3350bdacb2292575bd2d501b343f67c6264ab313 GIT binary patch literal 1568 zcmWIWW@Zs#fB;1XW3@Fa^nn}@<^bZ{%-qzH%7WCyf`XjP8sezy>F1`O zo0ylGmRek*SCN}Db)sX}VFMo5@0P9?>^^Q;eelT3b?Pr<4p^M(IXKOC$&_c`9<$v_ z(`bF^CG@N6zjS%KH_ul|^>3X#Z*K9gm>45vQ!D+$N_Mut#g|ZV-JgPyU)HWXE&H+7 z#K3>a1y85p>L9xpDb%t;XJxlNe7Y9RK;dNnUef^vUZtXWo=8^VDx%<9I$wdCRGUqKd`_ z%~jkBPw~{%?ptWQ=SkoyOYtcWoi6(srB2-VeU{{dBc~p$oN;hnXoRpbPne5Qv+mJd ziCKaVC-fc<6E>Rlc545VfZUr2-@9zTdwTEtUM_cX;jWC6x$AEf-<{o7wC;Nsd$964 z!K<4T!`3V_sNMPd?XRL_qxH%bjml?~H{{ermB=V8*&JoXZYAHS-B@zV%h{_ftLXY% zR^dm>Pd+$a{mAzJqlL>#o41AS$bM9{>W)j|Y96`sA&1x_3wPKN~J9_v01+(~JD5fvRY{-8Y1Z zfx(EAfq@$ssLAN4ArY)-g&nT1n&M8Vc8w_#-cTvV-oWc*X_I)zA%`$p5)O8 zQg#1Vay57T#n|%~mgZlYT;g(S)uJ6MrIvIbXmjOz&imZfPS8)Ue5sOj;#?D}rO(ri zy;`2BP2T14^7!oXi`i8>=cuTiR~0WztBB>|6*QOn;3=>+RwgVsDq2?h*^+Kk?bhzb z)$)Xjt6?w;+ z%Kcn-I$k~9)hJW)(Clc&TeG8kmEIP9u`yVFn$uFwa`nL}GtMjP?>VeGsm1uiewCit zvBAF_Px{>P2x!{&an97Bx4}_{r@2nADh=Mf`hx4?Q%SqUmK3O%7oN;fEZt(%Fnf8) zF>kB4k7p&zI24tnJb9A-=221{ifAeZ`Oxuxuy zaaX-KE265>yX%!L=Fe4H(W^Uim-j@|`Iki|znt&?qvD75=bd%ei>4_||IwLk8ek~zDM zD{_~2e!=9@j|GoQFF%x@_wy5|{$PU1hL=OYQ62?#< x6KjEpZUlN^f-u4lXf>i}LDz?#Ul95h19K5l?g{W_WdljF0O2bjt-uQ60RReIbL{{C literal 0 HcmV?d00001 diff --git a/OpenDocumentReader/Samples/sample-slides-it.odp b/OpenDocumentReader/Samples/sample-slides-it.odp new file mode 100644 index 0000000000000000000000000000000000000000..79ab34c26084558e9fdf3dc24a58f5dc204c46c9 GIT binary patch literal 1552 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 F2LODgg@OP8 literal 0 HcmV?d00001 diff --git a/OpenDocumentReader/Samples/sample-slides-pt-BR.odp b/OpenDocumentReader/Samples/sample-slides-pt-BR.odp new file mode 100644 index 0000000000000000000000000000000000000000..3219868d0183e7a233557678cdf84821814571a6 GIT binary patch literal 1564 zcmWIWW@Zs#fB;1XW3@Fa^nn}@<^bZ{%-qzH%7WCyf`XjP8sezy>F1`O zo0ylGmRek*SCN}Db)sX}VFMo5@0P9?>^^Q;eelT3b?Pr<4p^M(IXKOC$&_c`9<$v_ z(`bF^CG@N6zjS%KH_ul|^>3X#Z*K9gm>45vQ!D+$N_Mut#g|ZV-JgPyU)HWXE&H+7 z#K3>a1y85p>L9xpDb%t;XJxlNe7Y9RK;dNnUef^vUZtXWo=8^VDx%<9I$wdCRGUqKd`_ z%~jkBPw~{%?ptWQ=SkoyOYtcWoi6(srB2-VeU{{dBc~p$oN;hnXoRpbPne5Qv+mJd ziCKaVC-fc<6E>Rlc545VfZUr2-@9zTdwTEtUM_cX;jWC6x$AEf-<{o7wC;Nsd$964 z!K<4T!`3V_sNMPd?XRL_qxH%bjml?~H{{ermB=V8*&JoXZYAHS-B@zV%h{_ftLXY% zR^dm>Pd+$a{mAzJqlL>#o41AS$bM9{>W)j|Y96`sA&1x_3wPKN~J9_v01+(~JD5fyx~ssSv=# zz+k}1z`zX*)a3lU5>WaBhU(QY@4VXv0(XCluJTF^T!hc5SHzFBvmBCq+(> znYtur=jwS&XUi>cKjB+7sm~)aFvmQ!xkON6!-zkA=s9kgvh~p|SILZa|E7%G#~Wr{y7fY0hN#&!pF-)~ zI&EFAZMEdmj@PXh*qEp8ez^9Nz}t$)$C_^6v5wd#wSTj}+@j*c+ma9F>dmrh5jPOK z6Co+uux@piWnn<;-6@Or-k##Fa<`DDD#35M@Fx4BFzbLB^Ih*%bbG6Cra$=aDY1R` zvM-B8Ecc7~72P_@y{zTV(k)4CQ`-e^qDcLy_L{HptH=d9X^y`gHw8&2-0rks`OR81( zty=ozUE$NYy8nD1)RcKGU|T$6kM_i^`!5SkemUR2qW*{WiE_EyXSEOOmIPP6t7OYCv;L#JzIlsfP@%5Jv<6YL; zw~LSH{L^}NwE1XB?an`6-&!la-$?t8tweZKYSllcMOj7%cTxJyT1>H~u%z&ws9D-pV|7k>~v3=B&cLxD`J vg(12T=miPF2tT0Jh++j@A9`Lv=vxfTK}b0#z?+o~B*_AVuYj}+D~JaGkra7= literal 0 HcmV?d00001 diff --git a/OpenDocumentReader/Samples/sample-slides-ru.odp b/OpenDocumentReader/Samples/sample-slides-ru.odp new file mode 100644 index 0000000000000000000000000000000000000000..1c97459745fedf457572674b3dda86fc52079374 GIT binary patch literal 1624 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 AxBvhE literal 0 HcmV?d00001 diff --git a/OpenDocumentReader/Samples/sample-text-de.odt b/OpenDocumentReader/Samples/sample-text-de.odt new file mode 100644 index 0000000000000000000000000000000000000000..d5fa3ee2f2d23f13e978e0918aa9836aa26b4567 GIT binary patch literal 1575 zcmWIWW@Zs#fB;2?xMM~<>Oc+%a{zH}W^QUpWkG6UK|xMta$-qlex80=UW#6RVsU1% zUVcGpUP^v)X>Mv>iC#%+MM(hEFpyS7hPU4jv+M`5jx#bahyrO}*APctPd_*P+{C=h zwAA7fy^7qNsS^)!9Wvl)dtco(@psr)mDIWB^Bv?Dl*|;nwPnrB9ox&LE!34>=qmo) z|DS&)U-|n_(q$*vf;Q-yPf(tDA!3(?#r(dba(ty>mA>1K9{CjcWO3i6OIk0iCpoIm zR>|!5+%!>9$@JhCxz4A*_sy9*El)Ioy>#0CKT)qYNPNBf;Z|Pt^R5*?S2?Uvkdt1` zEvKY)!O$w(q(7i=@v=FSr@nSjeLUCkPjVX5$0aiUKa;EtkDC1Jo?THeqwYx9qsQW< z-xyGRoFVjA(FN$+o6HOhTtFWemsIAY7K6Om8+FxYiiD9K9*${^m*VHA!^~px3wr-(EnzQhN)8X z&7hwSepgpm#b~|TSsqmw6IZFQ{MDiE&q0OfduJ{y;`KfnyJ7~L+QDl~XPw0t>WZgd zpJqHcQ}!fh&dWO2b<*SdX-?%qcA)KkGRU%r+8wRsr!M1olJ}U_Bj3j`TE{tT@9zs7tj1~?}OfS^QErAimIO@>Y7hqdc-f9_v7&K zJL~Jv!rElg8sA_h1_m#71_o|mSSRP_m4MPAFtFE#9?ZLKz_a(cHpg2lb>BADR>9>C z8+i|xy==%~E}NWt>zbB#o62&}`n2h_$MSw|3@S_7ktt^3An4GgVfV>)(l&AP*+GY- zdPSpLbFB6yHE3O28Blp--+DXN>u=I5qh9C;bGEJYdnGiLTWGD!TZfF)!{5x3wgxVI z7Iq`xqfqXPNde(2_*QIqFKf0ls=GAWQsSP)_ltbyc^Wr<+a3N?QSt29&VE1MdI_#? z`$DeR*<5x$D6QS>6MXe_mv!NhL+d^t+#3B{zv<2Gi?bUgqx&9QD2lU=om4Mm(z?Bb zwR;=;S+2MB^G%bqAF6EHtND28gxejVcRt+tuma|W&%T`^&xEe?eJ;e|2b7|4*oR7Om%v5^o|*S>TCEX z%TB(+w%fisO84wD-9X39rW+R6-#eXeYToB9y;#umq1fbW|82HuzWKzweS=e+(?Qt+ z&!hiUCtRCy>Vrqr%cWfH9~zD=@c$r|HPuN=K;#;S{VjFTmePQ?|5jG3pX}a~8^jT^ z;+VRGsJ>+B;X8L;%sQmOqp~hANIYtteQn*u==R?;jw(!b`MP>roR1Fcu`SoX>@`r? z+;ZFMY~lKo-g&NfC#Jc|g;f;C9-4b`|Hpfdeb-*zoqu`NxoeSc|EaM~Y?yjbN10FZ zs2jU{W~=^I5tC?~ zy0Djs5IqbGOBj8DOswT2x)JC_48jO|pw);12VEa}c0uS{4a`JHnJ2)Tl?^1x0)(%C Jv^pz@2LSKxc(wol literal 0 HcmV?d00001 diff --git a/OpenDocumentReader/Samples/sample-text-en.odt b/OpenDocumentReader/Samples/sample-text-en.odt new file mode 100644 index 0000000000000000000000000000000000000000..3f47dae038ec265ffc76bc9bc6427391469bd1ed GIT binary patch literal 1537 zcmWIWW@Zs#fB;2?xMM~<>Oc+%a{zH}W^QUpWkG6UK|xMta$-qlex80=UW#6RVsU1% zUVcGpUP^v)X>Mv>iC#%+MM(hEFpyS7hPU4jv+M`5jx#bahyrO}*APctPd_*P+{C=h zwAA7fy^7qNsS^)!9Wvl)dtco(@psr)mDIWB^Bv?Dl*|;nwPnrB9ox&LE!34>=qmo) z|DS&)U-|n_(q$*vf;Q-yPf(tDA!3(?#r(dba(ty>mA>1K9{CjcWO3i6OIk0iCpoIm zR>|!5+%!>9$@JhCxz4A*_sy9*El)Ioy>#0CKT)qYNPNBf;Z|Pt^R5*?S2?Uvkdt1` zEvKY)!O$w(q(7i=@v=FSr@nSjeLUCkPjVX5$0aiUKa;EtkDC1Jo?THeqwYx9qsQW< z-xyGRoFVjA(FN$+o6HOhTtFWemsIAY7K6Om8+FxYiiD9K9*${^m*VHA!^~px3wr-(EnzQhN)8X z&7hwSepgpm#b~|TSsqmw6IZFQ{MDiE&q0OfduJ{y;`KfnyJ7~L+QDl~XPw0t>WZgd zpJqHcQ}!fh&dWO2b<*SdX-?%qcA)KkGRU%r+8wRsr!M1olJ}U_Bj3j`TE{tT@9zs7tj1~?}OfS^QErAimIO@>Y7hqdc-f9_v7&K zJL~Jv!g|h8b5UI;1_oJn1_o|mSSRP_m4MPAFtFE#=JwAv;Q4niUNAM@z54BzS1k>b z%`&at-d^&KoyEXp`JN3bQ#Qrhs~2>DW!YZ` z&J|A6W3Foct^FZrdue&j)EBEBu^w5f7sYwXwKa@yKFh^Z$C}NSt_|FnHtlwVA*WfH z>l*2R^(!j!WZNT}kL-%mX=8tEdF$X!UDoQ`<`L8M`(=8bzqqs^PxHp@q`>DNy!2V; zukFz44V}(g{6~qqdX@O~cOmRJx35q-d7=4(ciVrv zEHC~$oC_AONSf4bx0Kg-joBK@s_nuRliYd_`t&|d@LZaF(&udGl3>31{QD)_cf0v#D$k6#b13EW|BtnE-^i?FdsFc( zlQ*+X_u_;P92+w1?%iBJO_JU1%Mpjgz20Wb56mn=%&cV?Lw$re`xxSO&AT#-b5HA$ zg{PSEu1)$QsZ^uJ!ymV9!Qlj^8LXFn9$g*%N9)m!makKP9RL1t`AuG_Bi!p0gl04! zSakV~B)4wO#8J1STyoSOUz~hyoFz3wv1y(Zj&7gwYqs#9H#98-ZSUAdIjFT8$`5(Dk8b1cbiT Y!0dsPEdsn**+7ykK==wsGqQqs0HG>xw*UYD literal 0 HcmV?d00001 diff --git a/OpenDocumentReader/Samples/sample-text-es.odt b/OpenDocumentReader/Samples/sample-text-es.odt new file mode 100644 index 0000000000000000000000000000000000000000..298c5f39c678b0481256451c53249538a8ddf00c GIT binary patch literal 1579 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 T1$eWvfh1Xg@D-3YWCig63rT_O literal 0 HcmV?d00001 diff --git a/OpenDocumentReader/Samples/sample-text-it.odt b/OpenDocumentReader/Samples/sample-text-it.odt new file mode 100644 index 0000000000000000000000000000000000000000..917aed0a722fe24ed82dbe665b4b631cdeb2097d GIT binary patch literal 1567 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 Icd~+b0G!IR;{X5v literal 0 HcmV?d00001 diff --git a/OpenDocumentReader/Samples/sample-text-tr.odt b/OpenDocumentReader/Samples/sample-text-tr.odt new file mode 100644 index 0000000000000000000000000000000000000000..3e47969904ee10607011cfc6c9ab3a5599dc9ace GIT binary patch literal 1575 zcmWIWW@Zs#fB;2?xMM~<>Oc+%a{zH}W^QUpWkG6UK|xMta$-qlex80=UW#6RVsU1% zUVcGpUP^v)X>Mv>iC#%+MM(hEFpyS7hPU4jv+M`5jx#bahyrO}*APctPd_*P+{C=h zwAA7fy^7qNsS^)!9Wvl)dtco(@psr)mDIWB^Bv?Dl*|;nwPnrB9ox&LE!34>=qmo) z|DS&)U-|n_(q$*vf;Q-yPf(tDA!3(?#r(dba(ty>mA>1K9{CjcWO3i6OIk0iCpoIm zR>|!5+%!>9$@JhCxz4A*_sy9*El)Ioy>#0CKT)qYNPNBf;Z|Pt^R5*?S2?Uvkdt1` zEvKY)!O$w(q(7i=@v=FSr@nSjeLUCkPjVX5$0aiUKa;EtkDC1Jo?THeqwYx9qsQW< z-xyGRoFVjA(FN$+o6HOhTtFWemsIAY7K6Om8+FxYiiD9K9*${^m*VHA!^~px3wr-(EnzQhN)8X z&7hwSepgpm#b~|TSsqmw6IZFQ{MDiE&q0OfduJ{y;`KfnyJ7~L+QDl~XPw0t>WZgd zpJqHcQ}!fh&dWO2b<*SdX-?%qcA)KkGRU%r+8wRsr!M1olJ}U_Bj3j`TE{tT@9zs7tj1~?}OfS^QErAimIO@>Y7hqdc-f9_v7&K zJL~Jv!a8k0q)s)5O_x*1B9VdIh?MSDxj;qIkbLY&B@0%YMy0d<% zf_9qDb*&|nFY2oLxLQ5a;(hu4xcoiYz4Pz0Sb0_)`4i#ZnsTagVi@0imW!u;z0LFB zUR=XFEjqq2~d+@$;)TtS;G5w)%_jgkK) zyOnrv!o9wCN}k7SQ|=x%4rqG(B68nBp=F!zTs-&fOvSDvFC#Jp&98ZI=Q{mLW%_!R zjqjMPo&2ZYCl~xPvYYwv`fCY4Hlv<2Pn+G-9ya%#_Fwk>tyk6M?=LiSRXPsp@*JCf z{JY6b?j^Sp`d{x_v+c~YDJMKXXC%bc?|FXpewOzFk@i@(0EXPA!tNgucRj5C&-*y% zL(l4t$yukp4)f{92!_8^jodUtwMlZFgeJ?M>cVzexpPZWx`d&dRu81XI@#!Ll(8qEYXIImz1~L&)9uS$uxk;@60sk8Go4$dbe>& zZd&n;@1&**+o7#T(-a;}kEfMBo$nEMgM zCPEkX5)q<@fnf=wFOZ3~d_*?_y@){=VGpz#QQ)BKL(eV URL? { + guard let sample = screen.sample else { return nil } + + var source: URL? + for language in Bundle.main.preferredLocalizations + ["en"] { + source = Bundle.main.url(forResource: "\(sample.name)-\(language)", withExtension: sample.format) + + if source != nil { break } + } + + guard let source else { + assertionFailure("no \(sample.name) sample bundled - run scripts/make-screenshot-documents.py") + + return nil + } + + guard let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { + return nil + } + + let destination = documents.appendingPathComponent("\(screen.rawValue).\(sample.format)") + + do { + try? FileManager.default.removeItem(at: destination) + try FileManager.default.copyItem(at: source, to: destination) + } catch { + assertionFailure("could not lay out \(destination.lastPathComponent): \(error)") + + return nil + } + + return destination + } + + /// Marks the view the test waits for. Does nothing outside a screenshot run. + static func markReady(_ view: UIView) { + guard screen != nil else { return } + + view.accessibilityIdentifier = readyIdentifier + } +} diff --git a/OpenDocumentReaderUITests/ScreenshotTests.swift b/OpenDocumentReaderUITests/ScreenshotTests.swift new file mode 100644 index 0000000..5ba91d6 --- /dev/null +++ b/OpenDocumentReaderUITests/ScreenshotTests.swift @@ -0,0 +1,48 @@ +import XCTest + +/// The pictures the App Store shows. +/// +/// One launch per screen, each with `-ODRScreenshot `, which is the +/// app's own back door: it puts itself on that screen and stays there. Nothing +/// here taps anything, because everything there is to tap on the way - the +/// document browser - is Apple's UI in eleven languages. +/// +/// `fastlane snapshot` runs this once per language per device and collects what +/// it writes. `scripts/store-screenshots.py` then checks the set and stages it +/// for the release; the names below are the names it expects, and their order is +/// the order the store shows them in. +final class ScreenshotTests: XCTestCase { + + /// Long, because it covers translating a document on a simulator that is + /// also running eleven other languages' worth of tests today. + private let readyTimeout: TimeInterval = 180 + + override func setUpWithError() throws { + continueAfterFailure = false + } + + @MainActor + func testTakesTheStoreScreenshots() throws { + let app = XCUIApplication() + + // adds the language, the locale and fastlane's own arguments, once + setupSnapshot(app) + let arguments = app.launchArguments + + for (index, screen) in ["intro", "text", "sheet", "slides"].enumerated() { + app.launchArguments = arguments + ["-ODRScreenshot", screen] + app.launch() + + let ready = app.descendants(matching: .any)["odr-screenshot-ready"] + XCTAssertTrue( + ready.waitForExistence(timeout: readyTimeout), + "\(screen) never finished coming up, so there is nothing to photograph") + + snapshot(String(format: "%02d-%@", index + 1, screen)) + + // rather than leaving it running: the next launch has to go through + // didFinishLaunching again to be handed the next screen + app.terminate() + } + } +} diff --git a/OpenDocumentReaderUITests/SnapshotHelper.swift b/OpenDocumentReaderUITests/SnapshotHelper.swift new file mode 100644 index 0000000..fed8a2b --- /dev/null +++ b/OpenDocumentReaderUITests/SnapshotHelper.swift @@ -0,0 +1,320 @@ +// Copied verbatim from fastlane, which is where `snapshot` expects it, and MIT +// licensed like the rest of it - not our MPL 2.0. Update it by copying the file +// again rather than by editing it; the version marker at the bottom is what +// fastlane checks it against. +// +// https://github.com/fastlane/fastlane/blob/master/snapshot/lib/assets/SnapshotHelper.swift +// +// +// SnapshotHelper.swift +// Example +// +// Created by Felix Krause on 10/8/15. +// + +// ----------------------------------------------------- +// IMPORTANT: When modifying this file, make sure to +// increment the version number at the very +// bottom of the file to notify users about +// the new SnapshotHelper.swift +// ----------------------------------------------------- + +import Foundation +import XCTest + +@MainActor +func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) { + Snapshot.setupSnapshot(app, waitForAnimations: waitForAnimations) +} + +@MainActor +func snapshot(_ name: String, waitForLoadingIndicator: Bool) { + if waitForLoadingIndicator { + Snapshot.snapshot(name) + } else { + Snapshot.snapshot(name, timeWaitingForIdle: 0) + } +} + +/// - Parameters: +/// - name: The name of the snapshot +/// - timeout: Amount of seconds to wait until the network loading indicator disappears. Pass `0` if you don't want to wait. +@MainActor +func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) { + Snapshot.snapshot(name, timeWaitingForIdle: timeout) +} + +enum SnapshotError: Error, CustomDebugStringConvertible { + case cannotFindSimulatorHomeDirectory + case cannotRunOnPhysicalDevice + + var debugDescription: String { + switch self { + case .cannotFindSimulatorHomeDirectory: + return "Couldn't find simulator home location. Please, check SIMULATOR_HOST_HOME env variable." + case .cannotRunOnPhysicalDevice: + return "Can't use Snapshot on a physical device." + } + } +} + +@objcMembers +@MainActor +open class Snapshot: NSObject { + static var app: XCUIApplication? + static var waitForAnimations = true + static var cacheDirectory: URL? + static var screenshotsDirectory: URL? { + return cacheDirectory?.appendingPathComponent("screenshots", isDirectory: true) + } + static var deviceLanguage = "" + static var currentLocale = "" + + open class func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) { + + Snapshot.app = app + Snapshot.waitForAnimations = waitForAnimations + + do { + let cacheDir = try getCacheDirectory() + Snapshot.cacheDirectory = cacheDir + setLanguage(app) + setLocale(app) + setLaunchArguments(app) + } catch let error { + NSLog(error.localizedDescription) + } + } + + class func setLanguage(_ app: XCUIApplication) { + guard let cacheDirectory = self.cacheDirectory else { + NSLog("CacheDirectory is not set - probably running on a physical device?") + return + } + + let path = cacheDirectory.appendingPathComponent("language.txt") + + do { + let trimCharacterSet = CharacterSet.whitespacesAndNewlines + deviceLanguage = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet) + app.launchArguments += ["-AppleLanguages", "(\(deviceLanguage))"] + } catch { + NSLog("Couldn't detect/set language...") + } + } + + class func setLocale(_ app: XCUIApplication) { + guard let cacheDirectory = self.cacheDirectory else { + NSLog("CacheDirectory is not set - probably running on a physical device?") + return + } + + let path = cacheDirectory.appendingPathComponent("locale.txt") + + do { + let trimCharacterSet = CharacterSet.whitespacesAndNewlines + currentLocale = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet) + } catch { + NSLog("Couldn't detect/set locale...") + } + + if currentLocale.isEmpty && !deviceLanguage.isEmpty { + currentLocale = Locale(identifier: deviceLanguage).identifier + } + + if !currentLocale.isEmpty { + app.launchArguments += ["-AppleLocale", "\"\(currentLocale)\""] + } + } + + class func setLaunchArguments(_ app: XCUIApplication) { + guard let cacheDirectory = self.cacheDirectory else { + NSLog("CacheDirectory is not set - probably running on a physical device?") + return + } + + let path = cacheDirectory.appendingPathComponent("snapshot-launch_arguments.txt") + app.launchArguments += ["-FASTLANE_SNAPSHOT", "YES", "-ui_testing"] + + do { + let launchArguments = try String(contentsOf: path, encoding: String.Encoding.utf8) + let regex = try NSRegularExpression(pattern: "(\\\".+?\\\"|\\S+)", options: []) + let matches = regex.matches(in: launchArguments, options: [], range: NSRange(location: 0, length: launchArguments.count)) + let results = matches.map { result -> String in + (launchArguments as NSString).substring(with: result.range) + } + app.launchArguments += results + } catch { + NSLog("Couldn't detect/set launch_arguments...") + } + } + + open class func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) { + if timeout > 0 { + waitForLoadingIndicatorToDisappear(within: timeout) + } + + NSLog("snapshot: \(name)") // more information about this, check out https://docs.fastlane.tools/actions/snapshot/#how-does-it-work + + if Snapshot.waitForAnimations { + sleep(1) // Waiting for the animation to be finished (kind of) + } + + #if os(OSX) + guard let app = self.app else { + NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().") + return + } + + app.typeKey(XCUIKeyboardKeySecondaryFn, modifierFlags: []) + #else + + guard self.app != nil else { + NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().") + return + } + + let screenshot = XCUIScreen.main.screenshot() + #if os(iOS) && !targetEnvironment(macCatalyst) + let image = XCUIDevice.shared.orientation.isLandscape ? fixLandscapeOrientation(image: screenshot.image) : screenshot.image + #else + let image = screenshot.image + #endif + + guard var simulator = ProcessInfo().environment["SIMULATOR_DEVICE_NAME"], let screenshotsDir = screenshotsDirectory else { return } + + do { + // The simulator name contains "Clone X of " inside the screenshot file when running parallelized UI Tests on concurrent devices + let regex = try NSRegularExpression(pattern: "Clone [0-9]+ of ") + let range = NSRange(location: 0, length: simulator.count) + simulator = regex.stringByReplacingMatches(in: simulator, range: range, withTemplate: "") + + let path = screenshotsDir.appendingPathComponent("\(simulator)-\(name).png") + #if swift(<5.0) + try UIImagePNGRepresentation(image)?.write(to: path, options: .atomic) + #else + try image.pngData()?.write(to: path, options: .atomic) + #endif + } catch let error { + NSLog("Problem writing screenshot: \(name) to \(screenshotsDir)/\(simulator)-\(name).png") + NSLog(error.localizedDescription) + } + #endif + } + + class func fixLandscapeOrientation(image: UIImage) -> UIImage { + #if os(watchOS) + return image + #else + if #available(iOS 10.0, *) { + let format = UIGraphicsImageRendererFormat() + format.scale = image.scale + let renderer = UIGraphicsImageRenderer(size: image.size, format: format) + return renderer.image { context in + image.draw(in: CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height)) + } + } else { + return image + } + #endif + } + + class func waitForLoadingIndicatorToDisappear(within timeout: TimeInterval) { + #if os(tvOS) + return + #endif + + guard let app = self.app else { + NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().") + return + } + + let networkLoadingIndicator = app.otherElements.deviceStatusBars.networkLoadingIndicators.element + let networkLoadingIndicatorDisappeared = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == false"), object: networkLoadingIndicator) + _ = XCTWaiter.wait(for: [networkLoadingIndicatorDisappeared], timeout: timeout) + } + + class func getCacheDirectory() throws -> URL { + let cachePath = "Library/Caches/tools.fastlane" + // on OSX config is stored in /Users//Library + // and on iOS/tvOS/WatchOS it's in simulator's home dir + #if os(OSX) + let homeDir = URL(fileURLWithPath: NSHomeDirectory()) + return homeDir.appendingPathComponent(cachePath) + #elseif arch(i386) || arch(x86_64) || arch(arm64) + guard let simulatorHostHome = ProcessInfo().environment["SIMULATOR_HOST_HOME"] else { + throw SnapshotError.cannotFindSimulatorHomeDirectory + } + let homeDir = URL(fileURLWithPath: simulatorHostHome) + return homeDir.appendingPathComponent(cachePath) + #else + throw SnapshotError.cannotRunOnPhysicalDevice + #endif + } +} + +private extension XCUIElementAttributes { + var isNetworkLoadingIndicator: Bool { + if hasAllowListedIdentifier { return false } + + let hasOldLoadingIndicatorSize = frame.size == CGSize(width: 10, height: 20) + let hasNewLoadingIndicatorSize = frame.size.width.isBetween(46, and: 47) && frame.size.height.isBetween(2, and: 3) + + return hasOldLoadingIndicatorSize || hasNewLoadingIndicatorSize + } + + var hasAllowListedIdentifier: Bool { + let allowListedIdentifiers = ["GeofenceLocationTrackingOn", "StandardLocationTrackingOn"] + + return allowListedIdentifiers.contains(identifier) + } + + func isStatusBar(_ deviceWidth: CGFloat) -> Bool { + if elementType == .statusBar { return true } + guard frame.origin == .zero else { return false } + + let oldStatusBarSize = CGSize(width: deviceWidth, height: 20) + let newStatusBarSize = CGSize(width: deviceWidth, height: 44) + + return [oldStatusBarSize, newStatusBarSize].contains(frame.size) + } +} + +private extension XCUIElementQuery { + var networkLoadingIndicators: XCUIElementQuery { + let isNetworkLoadingIndicator = NSPredicate { (evaluatedObject, _) in + guard let element = evaluatedObject as? XCUIElementAttributes else { return false } + + return element.isNetworkLoadingIndicator + } + + return self.containing(isNetworkLoadingIndicator) + } + + @MainActor + var deviceStatusBars: XCUIElementQuery { + guard let app = Snapshot.app else { + fatalError("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().") + } + + let deviceWidth = app.windows.firstMatch.frame.width + + let isStatusBar = NSPredicate { (evaluatedObject, _) in + guard let element = evaluatedObject as? XCUIElementAttributes else { return false } + + return element.isStatusBar(deviceWidth) + } + + return self.containing(isStatusBar) + } +} + +private extension CGFloat { + func isBetween(_ numberA: CGFloat, and numberB: CGFloat) -> Bool { + return numberA...numberB ~= self + } +} + +// Please don't remove the lines below +// They are used to detect outdated configuration files +// SnapshotHelperVersion [1.30] diff --git a/README.md b/README.md index 4afa177..ea41752 100644 --- a/README.md +++ b/README.md @@ -120,13 +120,14 @@ in App Store Connect: gh workflow run release.yml -f version=1.38 ``` -It runs as four jobs: +It runs as five jobs: | job | what it does | | --- | --- | | `build` | one run producing both signed `.ipa`s, archived on the run | +| `screenshots` | beside the build: photographs the app on two devices, in every locale | | `upload` | one job per app, uploading its `.ipa` | -| `listing` | one job per app, writing what the store says about it | +| `listing` | one job per app, writing what the store says and shows about it | | `record` | once both landed: tag the build, draft the GitHub release | Both apps always go out together, and nothing chooses one: Pro and Lite are the @@ -169,6 +170,32 @@ an app's name is unique in the store, and the one sentence about ads inside the description. `review_information` and the categories are left out. See `fastlane/metadata/README.md`. +### The screenshots + +Taken during the release run rather than committed, because a screenshot is only +worth what the build it was taken from is worth: + +```sh +bundle exec fastlane ios screenshots +``` + +Four pictures per device - the onboarding pages, a text document, a spreadsheet +and a presentation - on a 6.9" iPhone and a 13" iPad, in the nine store locales +the app is translated into. `hi` and `sv` are given the English set, which is +what those storefronts would show anyway. The documents in them are localized +too, and written by `scripts/make-screenshot-documents.py`. + +Nothing is tapped to get there. The app takes `-ODRScreenshot ` in Debug +builds and puts itself on that screen, so the same picture comes out in every +language without driving Apple's document browser in eleven of them - see +`OpenDocumentReader/ScreenshotMode.swift` and the `ODR Screenshots` scheme. +`scripts/store-screenshots.py` checks the set against the sizes App Store +Connect takes, and the `listing` job hands it to `deliver` alongside the text. +The same set goes to both apps. + +The run archives them as the `screenshots` artifact, on a dry run too - which is +how to look at them before the store does. See `fastlane/screenshots/README.md`. + Nothing has to be committed to cut a release, and a release leaves no commit behind either. Both halves of the version come from outside the tree: @@ -232,6 +259,11 @@ ODR_DRY_RUN=true bundle exec fastlane deployPro # build and sign only separate jobs. `uploadPro` takes the `.ipa` already in `build/` rather than making one, and `resolveBuildNumber` prints the number both apps would get. +`uploadListingPro` writes the text and, if `fastlane/screenshots` holds a set, +the pictures with it - so fixing a word in a description by hand does not cost a +quarter of an hour of simulators, while a release run, which always captures +first, sends both. + ### Tags Nothing is triggered by a tag, and no tag is pushed before a build: a version diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 825c34c..b1c7eca 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -18,9 +18,23 @@ APPS = { # Resolved at parse time, which fastlane also does from fastlane/. IPA_DIR = File.expand_path("../build").freeze -# Absolute for the same reason. It resolves the repository from its own path, so -# it does not care which of the two it is called from. +# Absolute for the same reason. They resolve the repository from their own path, +# so they do not care which of the two they are called from. STORE_LISTING = File.expand_path("../scripts/store-listing.py").freeze +STORE_SCREENSHOTS = File.expand_path("../scripts/store-screenshots.py").freeze + +# Where snapshot writes and where the release run picks them up. Not committed: +# a picture of the app is worth what the build it was taken from is. +SCREENSHOT_DIR = File.expand_path("screenshots").freeze + +# The two sizes App Store Connect asks an app that runs on both for, named by +# whatever the runner's Xcode calls them. Newest first, and the first one the +# runner actually has wins - a simulator's name changes with every Xcode, the +# number of pixels it has does not, and store-screenshots.py checks that. +SCREENSHOT_DEVICES = [ + ["iPhone 17 Pro Max", "iPhone 16 Pro Max", "iPhone 15 Pro Max"], + ["iPad Pro 13-inch (M5)", "iPad Pro 13-inch (M4)", "iPad Pro (12.9-inch) (6th generation)"], +].freeze def dry_run? ENV["ODR_DRY_RUN"].to_s.strip == "true" @@ -31,6 +45,28 @@ def github_output(name, value) File.open(output, "a") { |out| out.write("#{name}=#{value}\n") } if output end +# The names of the simulators this machine has, whatever state they are in. +def available_simulators + `xcrun simctl list devices available`.lines.filter_map { |line| line[/^\s+(.+?) \([0-9A-F-]{36}\)/, 1] } +end + +# One simulator per device the store wants, or a failure naming what is here +# instead - which beats xcodebuild spending twenty minutes not finding it. +def screenshot_devices + given = ENV["ODR_SCREENSHOT_DEVICES"].to_s.strip + return given.split(",").map(&:strip) unless given.empty? + + available = available_simulators + + SCREENSHOT_DEVICES.map do |candidates| + candidates.find { |name| available.include?(name) } || + UI.user_error!( + "none of #{candidates.join(', ')} is installed. This machine has: " \ + "#{available.uniq.sort.join(', ')}. Set ODR_SCREENSHOT_DEVICES to pick by hand." + ) + end +end + platform :ios do desc "Build a signed .ipa of the paid app" lane :buildPro do @@ -62,6 +98,37 @@ platform :ios do upload_listing(APPS[:lite]) end + desc "Take the App Store screenshots of both devices, in every locale the app is translated into" + lane :screenshots do + languages = sh(STORE_SCREENSHOTS, "--languages", log: false).split("\n").map(&:strip).reject(&:empty?) + devices = screenshot_devices + + UI.message("photographing #{devices.join(' and ')} in #{languages.join(', ')}") + + # The Pro scheme, so no ad sdk is linked and no consent form can come up in + # front of a picture. The two apps are the same app, and what differs - the + # banner Lite carries - is not in a screenshot either way, so one set of + # pictures goes to both listings. + capture_ios_screenshots( + project: "OpenDocumentReader.xcodeproj", + scheme: "ODR Screenshots", + devices: devices, + languages: languages, + output_directory: SCREENSHOT_DIR, + # 9:41 and a full battery, as Apple's own screenshots have + override_status_bar: true, + # a rerun replaces the set rather than adding to it + clear_previous_screenshots: true, + # the runner has no browser to open the summary in, and no one watching it + skip_open_summary: true, + stop_after_first_error: true + ) + + # what came out is what the store would be given, so it is checked here + # rather than at upload time on the other side of the run + sh(STORE_SCREENSHOTS) + end + desc "Build and upload the paid app" lane :deployPro do build_ipa(APPS[:pro]) @@ -244,12 +311,22 @@ platform :ios do UI.user_error!("no version to write notes for: set ODR_VERSION (e.g. ODR_VERSION=1.41)") if version.empty? notes_dir = Dir.mktmpdir("release-notes") + shots_dir = Dir.mktmpdir("release-screenshots") key_path = nil begin # first, so a version some locale has no copy for fails before a key is # ever written to disk sh(STORE_LISTING, "--version", version, "--stage", notes_dir, "--app", options[:name]) + + # A run with nothing captured writes the text alone, so fixing a word in a + # description does not cost a quarter hour of simulators. A run with + # something captured has to have all of it: half a set is worse in the + # store than the set already up there. + captured = !Dir.glob(File.join(SCREENSHOT_DIR, "*", "*.png")).empty? + sh(STORE_SCREENSHOTS, "--stage", shots_dir) if captured + UI.important("no screenshots under #{SCREENSHOT_DIR} - writing the text only") unless captured + key_path = api_key_file upload_to_app_store( @@ -259,9 +336,13 @@ platform :ios do # usual case: the build is still processing when this runs app_version: version, metadata_path: notes_dir, + screenshots_path: shots_dir, skip_binary_upload: true, - skip_screenshots: true, + skip_screenshots: !captured, skip_metadata: false, + # the set replaces what is up there rather than joining it, or a locale + # keeps whichever of last release's pictures this one has no answer to + overwrite_screenshots: captured, # deliver otherwise opens an HTML preview and waits to be told it is fine force: true, # same as the binary upload: precheck cannot read in-app purchases with @@ -272,6 +353,7 @@ platform :ios do ensure FileUtils.remove_entry(File.dirname(key_path), true) if key_path FileUtils.remove_entry(notes_dir, true) + FileUtils.remove_entry(shots_dir, true) end end end diff --git a/fastlane/README.md b/fastlane/README.md index cd8c419..a070310 100644 --- a/fastlane/README.md +++ b/fastlane/README.md @@ -63,6 +63,14 @@ Write the paid app's listing, and ODR_VERSION's release notes, to the store Write the ad supported app's listing, and ODR_VERSION's release notes, to the store +### ios screenshots + +```sh +[bundle exec] fastlane ios screenshots +``` + +Take the App Store screenshots of both devices, in every locale the app is translated into + ### ios deployPro ```sh diff --git a/fastlane/screenshots/README.md b/fastlane/screenshots/README.md new file mode 100644 index 0000000..86f6d5c --- /dev/null +++ b/fastlane/screenshots/README.md @@ -0,0 +1,58 @@ +# Store screenshots + +What App Store Connect shows of the app, one directory per locale - written +here by a capture run, and not committed. This directory is empty in the +repository on purpose. + +The store copy next door is text somebody wrote, so it lives in git and the +release uploads what is committed. A screenshot is not written, it is taken: +it is only worth what the build it was taken from is worth, and a picture of +1.38 sitting in git through 1.41 is a picture of an app nobody can install any +more. So they are taken during the release run, from the build going out. + +```sh +bundle exec fastlane ios screenshots +``` + +That drives the app on both devices in every locale and writes them here. +`.gitignore` keeps what it wrote out of commits, and the release run archives +the same set as the `screenshots` artifact - which is how you look at what went +to the store, before it does on a dry run and after it on a real one. + +## What is in a set + +Four pictures per device, taken by relaunching the app onto one screen at a +time rather than by tapping through it. The screens, in the order the store +shows them: + +| | | +| --- | --- | +| `01-intro` | the onboarding pages, which is the app's own words | +| `02-text` | a text document open | +| `03-sheet` | a spreadsheet, with the sheet tabs under the tool bar | +| `04-slides` | a presentation | + +Two devices, because an app that runs on iPhone and iPad has to hand in both: a +6.9" iPhone and a 13" iPad. `scripts/store-screenshots.py` holds the sizes App +Store Connect accepts and checks the set against them; `Fastfile` holds the +simulators to look for, newest first, because what a simulator is called +changes with every Xcode and what it is worth does not. + +## Locales + +Eleven in the store, nine in the app. `de-DE`, `en-US`, `es-ES`, `fr-FR`, `it`, +`pl`, `pt-BR`, `ru` and `tr` are photographed in their own language. `hi` and +`sv` are given the English pictures, because the app has no Hindi or Swedish UI +either - that is what those storefronts would show whatever we upload. + +The documents in the pictures are localized too, which is most of what a reader +has to show: `scripts/make-screenshot-documents.py` writes them, they are +bundled into Debug builds only, and `-ODRScreenshot ` is how the app is +asked to open one. See `OpenDocumentReader/ScreenshotMode.swift`. + +## Both apps get the same pictures + +Pro and Lite are one app built twice, and the one thing that differs on screen - +the banner Lite carries - is not in a screenshot either way. The set is taken +once, with the Pro scheme, which links no ad sdk and so cannot raise a consent +form in front of the camera. Both listings are then given it. diff --git a/fastlane/screenshots/README.txt b/fastlane/screenshots/README.txt deleted file mode 100644 index 8b015ec..0000000 --- a/fastlane/screenshots/README.txt +++ /dev/null @@ -1,7 +0,0 @@ -Put all screenshots you want to use inside the folder of its language (e.g. en-US). -The device type will automatically be recognized using the image resolution. Apple TV screenshots -should be stored in a subdirectory named appleTV with language folders inside of it. iMessage -screenshots, like Apple TV screenshots, should also be stored in a subdirectory named iMessage -with language folders inside of it. - -The screenshots can be named whatever you want, but keep in mind they are sorted alphabetically. diff --git a/scripts/format.sh b/scripts/format.sh index 8a07f15..6c72cd7 100755 --- a/scripts/format.sh +++ b/scripts/format.sh @@ -27,6 +27,11 @@ if ! xcrun --find swift-format >/dev/null 2>&1; then exit 1 fi +# SnapshotHelper.swift is left out: it belongs to fastlane and is kept byte for +# byte as they ship it, so the next version can be copied over it. +# +# The comment stays out here. bash 3.2, which is the one macOS ships and the one +# CI runs, cannot parse an apostrophe in a comment inside a <( ). files=() while IFS= read -r -d '' file; do # tracked files can be staged for deletion and no longer exist on disk @@ -34,7 +39,8 @@ while IFS= read -r -d '' file; do files+=("$file") fi done < <( - git ls-files -z --cached --others --exclude-standard -- '*.swift' + git ls-files -z --cached --others --exclude-standard -- \ + '*.swift' ':!:OpenDocumentReaderUITests/SnapshotHelper.swift' ) if [ ${#files[@]} -eq 0 ]; then diff --git a/scripts/make-screenshot-documents.py b/scripts/make-screenshot-documents.py new file mode 100755 index 0000000..abf8bf9 --- /dev/null +++ b/scripts/make-screenshot-documents.py @@ -0,0 +1,393 @@ +#!/usr/bin/env python3 +"""Builds the documents the App Store screenshots are taken of. + +A reader's screenshots are mostly the document it is reading, so these are +written rather than borrowed: a short report, a small spreadsheet and a three +slide deck, in every language the app speaks and the store has a listing for. +The screenshot of the German store shows a German document. + +Kept small on purpose - a few kilobytes each, no images, no third party +material - because they are read once, on a simulator, to be photographed. +They are bundled in Debug builds only; `EXCLUDED_SOURCE_FILE_NAMES` keeps +`sample-*` out of the archive that goes to the store. + + python3 scripts/make-screenshot-documents.py + +Rerun it when the wording changes, and commit what it writes. The packages are +byte for byte reproducible, so a rerun that changes nothing leaves no diff. +""" + +import zipfile +from pathlib import Path +from xml.sax.saxutils import escape + +# 1980-01-01, what zip stores when it is given nothing: a rerun with the same +# words has to produce the same bytes, or every run is a commit +EPOCH = (1980, 1, 1, 0, 0, 0) + +SAMPLES = Path(__file__).resolve().parent.parent / "OpenDocumentReader" / "Samples" + +NAMESPACES = " ".join( + [ + 'xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"', + 'xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0"', + 'xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"', + 'xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0"', + 'xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0"', + 'xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"', + 'xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0"', + ] +) + +MANIFEST = """ + + + + + +""" + +# A4 upright for the report and the sheet, 16:9 for the deck, so a slide fills +# the width of a phone rather than sitting in a letterbox. +PAGE_LAYOUTS = { + "document": '', + "slide": '', +} + +# One accent, used for the report's headings and the slide titles, so the three +# documents read as one set. Blue, because the app's own tint is. +ACCENT = "#1c6fd6" +RULE = "#d4d9e0" + + +def styles(kind: str) -> str: + return f""" + + + + + {PAGE_LAYOUTS[kind]} + + + + + + +""" + + +def content(body: str, automatic: str = "") -> str: + return f""" + + +{automatic} + + +{body} + + +""" + + +def paragraph_style(name: str, *, size: str, weight: str = "normal", colour: str = "#1a1a1a", space: str = "0.4cm") -> str: + return f""" + + + """ + + +def report(words: dict) -> str: + """A page of text: a title, a paragraph, a heading and two more paragraphs.""" + automatic = "\n".join( + [ + paragraph_style("Title", size="26pt", weight="bold", space="0.8cm"), + paragraph_style("Heading", size="16pt", weight="bold", colour=ACCENT, space="0.3cm"), + paragraph_style("Body", size="12pt", space="0.5cm"), + ] + ) + lines = [ + f' {escape(words["title"])}', + f' {escape(words["lead"])}', + f' {escape(words["heading"])}', + f' {escape(words["body"])}', + f' {escape(words["closing"])}', + ] + return content(" \n" + "\n".join(lines) + "\n ", automatic) + + +def sheet(words: dict) -> str: + """Two sheets, so the tab bar under the document has something to show.""" + automatic = "\n".join( + [ + """ + + """, + """ + + + """ % (RULE, ACCENT), + """ + + + """ % (RULE,), + ] + ) + + def row(cells: list, style: str | None = None) -> str: + style = f' table:style-name="{style}"' if style else "" + out = [f" "] + for cell in cells: + if isinstance(cell, int): + out.append( + f' ' + f"{cell}" + ) + else: + out.append( + f' ' + f"{escape(cell)}" + ) + out.append(" ") + return "\n".join(out) + + rows = list(zip(words["rows"], ((1200, 1450), (480, 620), (3600, 900)))) + + overview = [row([words["item"], "Q1", "Q2", words["total"]], "ceHead")] + for label, (first, second) in rows: + overview.append(row([label, first, second, first + second])) + overview.append( + row( + [ + words["total"], + sum(first for _, (first, _) in rows), + sum(second for _, (_, second) in rows), + sum(first + second for _, (first, second) in rows), + ], + "ceTotal", + ) + ) + + costs = [row([words["item"], "Q2"], "ceHead")] + costs += [row([label, second]) for label, (_, second) in rows] + + tables = [] + for name, body in ((words["sheets"][0], overview), (words["sheets"][1], costs)): + columns = "\n".join([' '] * 4) + tables.append( + f' \n{columns}\n' + "\n".join(body) + "\n " + ) + + return content(" \n" + "\n".join(tables) + "\n ", automatic) + + +def deck(words: dict) -> str: + """Three slides. The first carries the bullets; the other two are their titles, + which is all the tab bar shows of them anyway.""" + automatic = "\n".join( + [ + paragraph_style("SlideTitle", size="32pt", weight="bold", colour=ACCENT, space="0cm"), + paragraph_style("Bullet", size="20pt", space="0.6cm"), + ] + ) + + def frame(x: str, y: str, width: str, height: str, lines: list) -> str: + text = "\n".join(f" {escape(line)}" for style, line in lines) + return ( + f' \n' + f" \n{text}\n \n" + f" " + ) + + pages = [ + f' \n' + + frame("2cm", "2cm", "24cm", "3cm", [("SlideTitle", words["deck_title"])]) + + "\n" + + frame("2cm", "6cm", "24cm", "7cm", [("Bullet", "• " + bullet) for bullet in words["bullets"]]) + + "\n " + ] + for name in words["slides"][1:]: + pages.append( + f' \n' + + frame("2cm", "2cm", "24cm", "3cm", [("SlideTitle", name)]) + + "\n " + ) + + return content(" \n" + "\n".join(pages) + "\n ", automatic) + + +# The words, per language the app is translated into that also has a store +# listing. `scripts/store-screenshots.py` owns which store locale reads which of +# these; a locale the app has no language for reads the English documents, +# because that is what its screenshot would show anyway. +# +# Short and plain on purpose: this is a document over someone's shoulder in a +# store screenshot, not copy that has to sell anything. +WORDS = { + "en": { + "title": "Quarterly report", + "lead": "The team met every goal of the second quarter, and the new release went out on time.", + "heading": "Highlights", + "body": "Costs stayed below budget, and two new partners joined the project.", + "closing": "The next meeting is at the end of July.", + "sheets": ["Overview", "Costs"], + "item": "Item", + "total": "Total", + "rows": ["Software", "Travel", "Hardware"], + "slides": ["Plan", "Schedule", "Team"], + "deck_title": "Project plan", + "bullets": ["Goals for the quarter", "Budget and costs", "Next steps"], + }, + "de": { + "title": "Quartalsbericht", + "lead": "Das Team hat alle Ziele des zweiten Quartals erreicht, und die neue Version ist pünktlich erschienen.", + "heading": "Das Wichtigste", + "body": "Die Kosten blieben unter dem Budget, und zwei neue Partner sind zum Projekt gestoßen.", + "closing": "Das nächste Treffen findet Ende Juli statt.", + "sheets": ["Übersicht", "Kosten"], + "item": "Position", + "total": "Gesamt", + "rows": ["Software", "Reisen", "Hardware"], + "slides": ["Plan", "Zeitplan", "Team"], + "deck_title": "Projektplan", + "bullets": ["Ziele für das Quartal", "Budget und Kosten", "Nächste Schritte"], + }, + "es": { + "title": "Informe trimestral", + "lead": "El equipo cumplió todos los objetivos del segundo trimestre y la nueva versión salió a tiempo.", + "heading": "Lo más destacado", + "body": "Los costes se mantuvieron por debajo del presupuesto y dos nuevos socios se sumaron al proyecto.", + "closing": "La próxima reunión será a finales de julio.", + "sheets": ["Resumen", "Costes"], + "item": "Concepto", + "total": "Total", + "rows": ["Software", "Viajes", "Hardware"], + "slides": ["Plan", "Calendario", "Equipo"], + "deck_title": "Plan del proyecto", + "bullets": ["Objetivos del trimestre", "Presupuesto y costes", "Próximos pasos"], + }, + "fr": { + "title": "Rapport trimestriel", + "lead": "L'équipe a atteint tous les objectifs du deuxième trimestre et la nouvelle version est sortie à temps.", + "heading": "Points forts", + "body": "Les coûts sont restés en dessous du budget et deux nouveaux partenaires ont rejoint le projet.", + "closing": "La prochaine réunion aura lieu fin juillet.", + "sheets": ["Aperçu", "Coûts"], + "item": "Poste", + "total": "Total", + "rows": ["Logiciels", "Déplacements", "Matériel"], + "slides": ["Plan", "Calendrier", "Équipe"], + "deck_title": "Plan du projet", + "bullets": ["Objectifs du trimestre", "Budget et coûts", "Prochaines étapes"], + }, + "it": { + "title": "Relazione trimestrale", + "lead": "Il team ha raggiunto tutti gli obiettivi del secondo trimestre e la nuova versione è uscita in tempo.", + "heading": "In evidenza", + "body": "I costi sono rimasti sotto il budget e due nuovi partner si sono uniti al progetto.", + "closing": "Il prossimo incontro è previsto per la fine di luglio.", + "sheets": ["Panoramica", "Costi"], + "item": "Voce", + "total": "Totale", + "rows": ["Software", "Viaggi", "Hardware"], + "slides": ["Piano", "Calendario", "Team"], + "deck_title": "Piano del progetto", + "bullets": ["Obiettivi del trimestre", "Budget e costi", "Prossimi passi"], + }, + "pl": { + "title": "Raport kwartalny", + "lead": "Zespół osiągnął wszystkie cele drugiego kwartału, a nowa wersja ukazała się na czas.", + "heading": "Najważniejsze", + "body": "Koszty pozostały poniżej budżetu, a do projektu dołączyło dwóch nowych partnerów.", + "closing": "Następne spotkanie odbędzie się pod koniec lipca.", + "sheets": ["Przegląd", "Koszty"], + "item": "Pozycja", + "total": "Razem", + "rows": ["Oprogramowanie", "Podróże", "Sprzęt"], + "slides": ["Plan", "Harmonogram", "Zespół"], + "deck_title": "Plan projektu", + "bullets": ["Cele na kwartał", "Budżet i koszty", "Następne kroki"], + }, + "pt-BR": { + "title": "Relatório trimestral", + "lead": "A equipe alcançou todas as metas do segundo trimestre, e a nova versão saiu no prazo.", + "heading": "Destaques", + "body": "Os custos ficaram abaixo do orçamento, e dois novos parceiros entraram no projeto.", + "closing": "A próxima reunião será no fim de julho.", + "sheets": ["Visão geral", "Custos"], + "item": "Item", + "total": "Total", + "rows": ["Software", "Viagens", "Hardware"], + "slides": ["Plano", "Cronograma", "Equipe"], + "deck_title": "Plano do projeto", + "bullets": ["Metas do trimestre", "Orçamento e custos", "Próximos passos"], + }, + "ru": { + "title": "Квартальный отчёт", + "lead": "Команда выполнила все задачи второго квартала, и новая версия вышла вовремя.", + "heading": "Главное", + "body": "Расходы остались в рамках бюджета, а к проекту присоединились два новых партнёра.", + "closing": "Следующая встреча — в конце июля.", + "sheets": ["Обзор", "Расходы"], + "item": "Статья", + "total": "Итого", + "rows": ["Программы", "Поездки", "Оборудование"], + "slides": ["План", "График", "Команда"], + "deck_title": "План проекта", + "bullets": ["Цели на квартал", "Бюджет и расходы", "Следующие шаги"], + }, + "tr": { + "title": "Üç aylık rapor", + "lead": "Ekip ikinci çeyreğin tüm hedeflerine ulaştı ve yeni sürüm zamanında yayımlandı.", + "heading": "Öne çıkanlar", + "body": "Maliyetler bütçenin altında kaldı ve projeye iki yeni ortak katıldı.", + "closing": "Bir sonraki toplantı temmuz sonunda yapılacak.", + "sheets": ["Genel bakış", "Maliyetler"], + "item": "Kalem", + "total": "Toplam", + "rows": ["Yazılım", "Seyahat", "Donanım"], + "slides": ["Plan", "Takvim", "Ekip"], + "deck_title": "Proje planı", + "bullets": ["Çeyrek hedefleri", "Bütçe ve maliyetler", "Sonraki adımlar"], + }, +} + +# What the app asks the bundle for, and what the screenshot test names. +DOCUMENTS = { + "text": ("odt", "application/vnd.oasis.opendocument.text", "document", report), + "sheet": ("ods", "application/vnd.oasis.opendocument.spreadsheet", "document", sheet), + "slides": ("odp", "application/vnd.oasis.opendocument.presentation", "slide", deck), +} + + +def write(path: Path, mimetype: str, kind: str, content_xml: str) -> None: + with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as package: + def entry(name: str, text: str, stored: bool = False) -> None: + info = zipfile.ZipInfo(name, date_time=EPOCH) + info.external_attr = 0o644 << 16 + package.writestr( + info, text, compress_type=zipfile.ZIP_STORED if stored else zipfile.ZIP_DEFLATED + ) + + # first and uncompressed, or the package is only recognised by sniffing + entry("mimetype", mimetype, stored=True) + entry("META-INF/manifest.xml", MANIFEST.format(mimetype=mimetype)) + entry("styles.xml", styles(kind)) + entry("content.xml", content_xml) + + +def main() -> None: + SAMPLES.mkdir(parents=True, exist_ok=True) + + written = 0 + for language, words in WORDS.items(): + for name, (extension, mimetype, kind, build) in DOCUMENTS.items(): + path = SAMPLES / f"sample-{name}-{language}.{extension}" + write(path, mimetype, kind, build(words)) + written += 1 + + print(f"wrote {written} documents in {len(WORDS)} languages to {SAMPLES}") + + +if __name__ == "__main__": + main() diff --git a/scripts/store-screenshots.py b/scripts/store-screenshots.py new file mode 100755 index 0000000..a3d40a6 --- /dev/null +++ b/scripts/store-screenshots.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python3 +# +# The App Store screenshots: which ones there are, and the deliver tree built +# out of what a capture run wrote. +# +# Unlike the store copy, these are not committed. A picture of the app is only +# worth as much as the app it was taken from, so they are taken during the +# release run, from the build going out, and handed to deliver from there. +# `fastlane ios screenshots` takes them; this says what a full set is. +# +# scripts/store-screenshots.py --languages what to capture +# scripts/store-screenshots.py check what was captured +# scripts/store-screenshots.py --stage DIR check it and stage it +# +# The store has eleven locales and the app is translated into nine of them. +# The other two get the English pictures, which is what their storefront would +# show anyway: the app has no UI in Hindi or Swedish either. + +import argparse +import os +import shutil +import struct +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +SCREENSHOTS = ROOT / "fastlane" / "screenshots" + +# Store locale -> the language the app is in when it is photographed for it. +# `None` means the app has none, so that locale reads the English pictures. +# The keys are the locales `fastlane/metadata` has; the values are what iOS +# resolves the locale to, which is why de-DE is one folder and pt-BR is another. +LOCALES = { + "de-DE": "de", + "en-US": "en", + "es-ES": "es", + "fr-FR": "fr", + "hi": None, + "it": "it", + "pl": "pl", + "pt-BR": "pt-BR", + "ru": "ru", + "sv": None, + "tr": "tr", +} + +FALLBACK = "en-US" + +# What one device shows, in the order the store shows them. The same names the +# screenshot test writes - see `OpenDocumentReaderUITests/ScreenshotTests.swift`. +SCREENS = ("01-intro", "02-text", "03-sheet", "04-slides") + +# What App Store Connect accepts, upright, in pixels. An app that runs on both +# has to hand in both, and the store fits every smaller iPhone and iPad from +# these two. More than one size per device because which simulator a runner has +# depends on the Xcode it is running. +SIZES = { + "iphone": { + (1320, 2868), # 6.9", iPhone 16 Pro Max and later + (1290, 2796), # 6.9"/6.7", iPhone 15 Pro Max and 16 Plus + (1284, 2778), # 6.5", iPhone 12/13 Pro Max + (1242, 2688), # 6.5", iPhone 11 Pro Max + }, + "ipad": { + (2064, 2752), # 13", iPad Pro M4 + (2048, 2732), # 12.9", iPad Pro + }, +} + + +def languages(): + """The locales worth capturing: the ones the app can be photographed in.""" + return [locale for locale, language in LOCALES.items() if language] + + +def borrowed(): + """The locales that read another one's pictures.""" + return [locale for locale, language in LOCALES.items() if not language] + + +def size(path): + """The pixel size of a PNG, off its header rather than through a library.""" + with path.open("rb") as file: + header = file.read(24) + + if len(header) < 24 or header[:8] != b"\x89PNG\r\n\x1a\n" or header[12:16] != b"IHDR": + raise ValueError(f"{path.name} is not a PNG") + + return struct.unpack(">II", header[16:24]) + + +def device(width, height): + """Which device a picture that size belongs to, or None.""" + for name, sizes in SIZES.items(): + if (width, height) in sizes: + return name + + return None + + +def collect(directory): + """What one capture run wrote. Returns (files by locale and device, problems). + + snapshot names its output `-.png`, one folder per + language, so the simulator's name is read off the front and the size decides + which device it counts as - the name of a simulator changes with Xcode, the + number of pixels it has does not. + """ + directory = Path(directory) + found = {} + problems = [] + + for locale in languages(): + folder = directory / locale + if not folder.is_dir(): + problems.append(f"{locale}: no {folder}") + continue + + pictures = {} + for path in sorted(folder.glob("*.png")): + screen = next((name for name in SCREENS if path.stem.endswith(name)), None) + if screen is None: + problems.append(f"{locale}: {path.name} is not one of {', '.join(SCREENS)}") + continue + + try: + width, height = size(path) + except (OSError, ValueError) as reason: + problems.append(f"{locale}: {reason}") + continue + + kind = device(width, height) + if kind is None: + problems.append( + f"{locale}: {path.name} is {width}x{height}, which is no size the store takes" + ) + continue + + pictures.setdefault(kind, {})[screen] = path + + for kind in SIZES: + missing = [screen for screen in SCREENS if screen not in pictures.get(kind, {})] + if missing: + problems.append(f"{locale}: no {kind} {', '.join(missing)}") + + found[locale] = pictures + + return found, problems + + +def stage(found, directory): + """Write the screenshot tree deliver uploads. + + A folder per store locale, the borrowed ones copied from the English rather + than left out: what deliver does not upload for a locale, App Store Connect + keeps - which would be whatever was there before this release. + """ + directory = Path(directory) + + for locale, pictures in found.items(): + folder = directory / locale + folder.mkdir(parents=True, exist_ok=True) + for kind, screens in pictures.items(): + for screen, path in screens.items(): + shutil.copyfile(path, folder / f"{kind}-{screen}.png") + + for locale in borrowed(): + source = directory / FALLBACK + target = directory / locale + shutil.rmtree(target, ignore_errors=True) + shutil.copytree(source, target) + + return directory + + +def fail(message): + if os.environ.get("GITHUB_ACTIONS"): + # also surfaces as an annotation on the run, not only inside the step log + print(f"::error::{message}") + else: + print(message, file=sys.stderr) + return 1 + + +def main(argv=None): + parser = argparse.ArgumentParser( + description="Check a run of App Store screenshots, and stage it for deliver." + ) + parser.add_argument( + "--languages", + action="store_true", + help="print the locales to capture, one per line, and do nothing else", + ) + parser.add_argument( + "--screenshots", + metavar="DIR", + default=SCREENSHOTS, + help=f"where the capture run wrote (default {SCREENSHOTS.relative_to(ROOT)})", + ) + parser.add_argument( + "--stage", + metavar="DIR", + help="also write the deliver screenshot tree into DIR", + ) + args = parser.parse_args(argv) + + if args.languages: + print("\n".join(languages())) + return 0 + + found, problems = collect(args.screenshots) + + if problems: + return fail( + "no full set of screenshots to release with:\n " + + "\n ".join(problems) + + "\nRun `bundle exec fastlane ios screenshots` to take them." + ) + + if args.stage: + try: + stage(found, args.stage) + except OSError as reason: + return fail(str(reason)) + print( + f"staged {len(SCREENS)} screenshots per device for " + f"{len(found) + len(borrowed())} locales in {args.stage}" + ) + else: + pictures = sum(len(screens) for locale in found.values() for screens in locale.values()) + print(f"{pictures} screenshots in all {len(found)} captured locales: {', '.join(found)}") + + return 0 + + +if __name__ == "__main__": + sys.exit(main())