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 0000000..07eba2a Binary files /dev/null and b/OpenDocumentReaderTests/test-encrypted.doc differ