From d0f0368ceffb94d6ae71647bafd42dc936520447 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Fri, 21 Aug 2026 09:14:06 +0200 Subject: [PATCH] Take the new core, and say when a legacy file is locked odrcore 6.10.0. A password protected .doc, .ppt or .xls now reports itself as such, and since no password opens one, the reader says so rather than asking or handing the file to the web view. The rest comes with the core: PDF text copies as words, marking it highlights them, a few more PDFs open, and a repeating table header keeps its rows. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_013Jjxr44wSfKMQegPbPBbVv --- CHANGELOG.md | 11 ++ OpenDocumentReader.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- OpenDocumentReader/CoreWrapper.swift | 7 + .../DocumentViewController.swift | 12 +- .../LockedDocumentTests.swift | 122 ++++++++++++++++++ OpenDocumentReaderTests/test-encrypted.doc | Bin 0 -> 9216 bytes 7 files changed, 153 insertions(+), 5 deletions(-) create mode 100644 OpenDocumentReaderTests/LockedDocumentTests.swift create mode 100644 OpenDocumentReaderTests/test-encrypted.doc diff --git a/CHANGELOG.md b/CHANGELOG.md index 00706f8..641854b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,17 @@ once the version tag exists. ## [Unreleased] +### Added + +- A password protected Word, Excel or PowerPoint file says so, instead of + failing to open for no stated reason. + +### Changed + +- Text copied out of a PDF reads as words, marking it highlights the words, and + a few more PDFs open at all. +- Documents keep the rows of a repeating table header, which had gone missing. + ### Fixed - A long document opens at the top of its first page on an iPad. It opened a diff --git a/OpenDocumentReader.xcodeproj/project.pbxproj b/OpenDocumentReader.xcodeproj/project.pbxproj index 21446e8..06e7cd2 100644 --- a/OpenDocumentReader.xcodeproj/project.pbxproj +++ b/OpenDocumentReader.xcodeproj/project.pbxproj @@ -830,7 +830,7 @@ repositoryURL = "https://github.com/opendocument-app/OpenDocument.core.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 6.9.0; + minimumVersion = 6.10.0; }; }; AD584FCD41577C8CDEE974AA /* XCRemoteSwiftPackageReference "swift-package-manager-google-mobile-ads" */ = { diff --git a/OpenDocumentReader.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/OpenDocumentReader.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 9fcc859..94b725a 100644 --- a/OpenDocumentReader.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/OpenDocumentReader.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/opendocument-app/OpenDocument.core.git", "state" : { - "revision" : "fb696177bed250b4e2c0aebae326ea6025bca49e", - "version" : "6.9.0" + "revision" : "9edbc14c1844cd432820b268f18e9b58e5a8b28a", + "version" : "6.10.0" } }, { diff --git a/OpenDocumentReader/CoreWrapper.swift b/OpenDocumentReader/CoreWrapper.swift index 2a74f25..f59f59a 100644 --- a/OpenDocumentReader/CoreWrapper.swift +++ b/OpenDocumentReader/CoreWrapper.swift @@ -9,6 +9,9 @@ let CoreWrapperErrorDomain = "app.opendocument.CoreWrapperErrorDomain" case wrongPassword = 2 /// Not something odrcore renders for us — see the guard in `translate`. case unsupportedFileType = 3 + /// Locked, and odrcore has no way in whatever the password — a legacy Word, + /// Excel or PowerPoint file. + case undecryptable = 4 } private func coreWrapperError(_ code: CoreWrapperError, _ description: String) -> NSError { @@ -116,6 +119,10 @@ private func isCsv(_ file: DecodedFile) -> Bool { file.fileType == .commaSeparat where error.code == ODRError.wrongPassword.rawValue { throw coreWrapperError(.wrongPassword, "wrong password") + } catch let error as NSError + where error.code == ODRError.unsupportedOperation.rawValue + { + throw coreWrapperError(.undecryptable, "odrcore cannot decrypt this format") } } diff --git a/OpenDocumentReader/DocumentViewController.swift b/OpenDocumentReader/DocumentViewController.swift index 53d6c4a..f76fa3b 100644 --- a/OpenDocumentReader/DocumentViewController.swift +++ b/OpenDocumentReader/DocumentViewController.swift @@ -853,8 +853,14 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel // attention: wrong for extensions like ".pages.zip" let fileType = doc.fileURL.pathExtension.lowercased() + // no password opens one of these, so asking for one would only ask + // again, and the web view makes nothing of a locked file either + let isLocked = + (error as NSError).domain == CoreWrapperErrorDomain + && (error as NSError).code == CoreWrapperError.undecryptable.rawValue + let fileName = doc.fileURL.absoluteString.lowercased() - if systemRenderedExtensions.contains(where: fileName.hasSuffix) { + if !isLocked, systemRenderedExtensions.contains(where: fileName.hasSuffix) { // not odrcore's to render, but the web view knows the format documentNavigation = self.webview.loadFileURL(doc.fileURL, allowingReadAccessTo: doc.fileURL) @@ -873,7 +879,9 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel documentNavigation = nil loadMessage( - "

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

\(NSLocalizedString("toast_error_generic", comment: ""))" + "

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

" + + NSLocalizedString( + isLocked ? "toast_error_password_protected" : "toast_error_generic", comment: "") ) AnalyticsManager.shared.report( diff --git a/OpenDocumentReaderTests/LockedDocumentTests.swift b/OpenDocumentReaderTests/LockedDocumentTests.swift new file mode 100644 index 0000000..62e6ec8 --- /dev/null +++ b/OpenDocumentReaderTests/LockedDocumentTests.swift @@ -0,0 +1,122 @@ +import WebKit +import XCTest + +@testable import OpenDocumentReader + +/// A legacy Word, Excel or PowerPoint file can say it is password protected, +/// and no password opens it: odrcore reads the flag but cannot decrypt any of +/// them. The reader has to say so rather than ask, and rather than hand the +/// file to the web view, which makes nothing of it either. +class LockedDocumentTests: XCTestCase { + private let temporaryDirectory = NSTemporaryDirectory() + private var documentURL: URL! + private var window: UIWindow! + private var controller: DocumentViewController! + private var document: Document! + + override func setUpWithError() throws { + documentURL = try copyFixture() + } + + override func tearDown() { + window?.isHidden = true + window = nil + controller = nil + document = nil + } + + /// Out of the read-only test bundle, and away from the temporary directory + /// translating uses for its cache and output. + private func copyFixture() throws -> URL { + let documentsURL = try FileManager.default.url( + for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false) + + let url = documentsURL.appendingPathComponent("locked.doc") + try? FileManager.default.removeItem(at: url) + + let bundlePath = try XCTUnwrap( + Bundle(for: Self.self).path(forResource: "test-encrypted", ofType: "doc")) + try FileManager.default.copyItem(at: URL(fileURLWithPath: bundlePath), to: url) + + return url + } + + /// Its own code, not `wrongPassword`: that one puts the prompt up, and the + /// prompt would only come back. + func testALockedLegacyFileReportsItsOwnError() throws { + let wrapper = CoreWrapper() + + for password in [nil, "secret"] { + XCTAssertThrowsError( + try wrapper.translate( + documentURL.path, cache: temporaryDirectory, into: temporaryDirectory, with: password, + editable: false) + ) { error in + XCTAssertEqual((error as NSError).code, CoreWrapperError.undecryptable.rawValue) + } + } + } + + /// `.doc` is one of the formats the reader otherwise falls back to the web + /// view for, so the message has to beat the fallback. + func testALockedLegacyFileSaysWhyItDidNotOpen() throws { + try present(documentURL) + + let opened = expectation(description: "opened") + document.open { _ in opened.fulfill() } + wait(for: [opened], timeout: 60) + + waitForPage(where: "document.body.innerText.length > 0") + + let shown = try XCTUnwrap(evaluate("document.body.innerText") as? String) + XCTAssertTrue( + shown.contains(NSLocalizedString("toast_error_password_protected", comment: "")), shown) + XCTAssertFalse(controller.webview.url?.isFileURL ?? false, "the file was handed to the web view") + } + + // MARK: - helpers + + private func present(_ url: URL) throws { + let storyboard = UIStoryboard(name: "Main", bundle: Bundle(for: DocumentViewController.self)) + controller = try XCTUnwrap( + storyboard.instantiateViewController(withIdentifier: "TextDocumentViewController") + as? DocumentViewController) + + document = Document(fileURL: url) + controller.document = document + + window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) + window.rootViewController = controller + window.makeKeyAndVisible() + + controller.view.layoutIfNeeded() + } + + private func waitForPage( + where condition: String, file: StaticString = #filePath, line: UInt = #line + ) { + let deadline = Date().addingTimeInterval(60) + + while Date() < deadline { + if evaluate(condition) as? Bool == true { return } + + _ = XCTWaiter.wait(for: [expectation(description: "a turn of the run loop")], timeout: 0.1) + } + + XCTFail("timed out waiting for \(condition)", file: file, line: line) + } + + @discardableResult + private func evaluate(_ script: String) -> Any? { + let done = expectation(description: "evaluated") + var result: Any? + + controller.webview.evaluateJavaScript(script) { value, _ in + result = value + done.fulfill() + } + wait(for: [done], timeout: 30) + + return result + } +} diff --git a/OpenDocumentReaderTests/test-encrypted.doc b/OpenDocumentReaderTests/test-encrypted.doc new file mode 100644 index 0000000000000000000000000000000000000000..07eba2a621859ad5f00584d635e364c9264ee65c GIT binary patch literal 9216 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