From 92a2e773e9972ef28666eca1e90f147f416bcf30 Mon Sep 17 00:00:00 2001 From: Marten Rebane Date: Tue, 22 Sep 2026 16:17:21 +0300 Subject: [PATCH 1/2] Fix opening files from other apps --- .../Diagnostics/ShareExtensionLogging.swift | 42 +++++++ .../UI/ShareViewController.swift | 100 ++++++++++++++++- .../ViewModel/ShareViewModel.swift | 104 +++++++++++------- .../ViewModel/ShareViewModelTests.swift | 48 +++++++- RIADigiDoc.xcodeproj/project.pbxproj | 1 + .../FileOpening/FileOpeningService.swift | 50 +++++++-- RIADigiDoc/ViewModel/HomeViewModel.swift | 25 ++++- .../FileOpening/FileOpeningServiceTests.swift | 23 ++++ .../ViewModel/HomeViewModelTests.swift | 59 ++++++++++ 9 files changed, 395 insertions(+), 57 deletions(-) create mode 100644 Extensions/FileImportShareExtension/Diagnostics/ShareExtensionLogging.swift diff --git a/Extensions/FileImportShareExtension/Diagnostics/ShareExtensionLogging.swift b/Extensions/FileImportShareExtension/Diagnostics/ShareExtensionLogging.swift new file mode 100644 index 00000000..af0b083d --- /dev/null +++ b/Extensions/FileImportShareExtension/Diagnostics/ShareExtensionLogging.swift @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: LGPL-2.1-or-later + +import Foundation +import os + +enum ShareExtensionLogging { + static let subsystem = "ee.ria.digidoc.shareext.logging" + static let category = "import" + + private static let log = Logger(subsystem: subsystem, category: category) + + static func emit(_ message: String) { + log.info("\(message, privacy: .public)") + } + + static func nameAttributes(_ name: String) -> String { + let ext = (name as NSString).pathExtension.lowercased() + return "nameLen=\(name.count) ext=\(ext.isEmpty ? "none" : ext) dotLead=\(name.hasPrefix("."))" + } + + static var availableMemoryMB: Int { + #if os(iOS) + os_proc_available_memory() / (1024 * 1024) + #else + -1 + #endif + } + + static func fileSize(of url: URL) -> Int { + let attributes = try? FileManager.default.attributesOfItem(atPath: url.path) + return (attributes?[.size] as? NSNumber)?.intValue ?? -1 + } + + static func streamState(_ label: String, _ stream: Stream?) -> String { + guard let stream else { + return "\(label)Nil=true" + } + let errorCode = (stream.streamError as NSError?)?.code ?? 0 + return "\(label)Nil=false \(label)Status=\(stream.streamStatus.rawValue) \(label)Err=\(errorCode)" + } +} diff --git a/Extensions/FileImportShareExtension/UI/ShareViewController.swift b/Extensions/FileImportShareExtension/UI/ShareViewController.swift index dfb58f95..002ebf50 100644 --- a/Extensions/FileImportShareExtension/UI/ShareViewController.swift +++ b/Extensions/FileImportShareExtension/UI/ShareViewController.swift @@ -7,6 +7,7 @@ import SwiftUI import UniformTypeIdentifiers import FactoryKit import UtilsLib +import os class ShareViewController: UIViewController, Sendable, Loggable { let viewModel = Container.shared.shareViewModel() @@ -44,9 +45,29 @@ class ShareViewController: UIViewController, Sendable, Loggable { } @MainActor - func loadItem(for provider: NSItemProvider, typeIdentifier: String) async throws -> URL { + func loadItem(for provider: NSItemProvider, typeIdentifier: String, diagIndex: Int = -1) async throws -> URL { + let diagStart = ContinuousClock.now + ShareExtensionLogging.emit("phase=loadItem.request idx=\(diagIndex) type=\(typeIdentifier)") + return try await withCheckedThrowingContinuation { continuation in provider.loadItem(forTypeIdentifier: typeIdentifier, options: nil) { item, error in + let diagLog = Logger(subsystem: "ee.ria.digidoc.shareext.logging", category: "import") + let diagComponents = diagStart.duration(to: .now).components + let diagElapsedMs = diagComponents.seconds * 1000 + + diagComponents.attoseconds / 1_000_000_000_000_000 + let diagError = error as NSError? + let diagItemClass = item.map { String(describing: type(of: $0)) } ?? "nil" + diagLog.info( + """ + phase=loadItem.callback idx=\(diagIndex, privacy: .public) \ + elapsedMs=\(diagElapsedMs, privacy: .public) \ + itemClass=\(diagItemClass, privacy: .public) \ + hasError=\(error != nil, privacy: .public) \ + errDomain=\(diagError?.domain ?? "none", privacy: .public) \ + errCode=\(diagError?.code ?? 0, privacy: .public) + """ + ) + if let error = error { continuation .resume( @@ -54,11 +75,23 @@ class ShareViewController: UIViewController, Sendable, Loggable { ) } else if item != nil { if let itemData = item as? Data { + diagLog.info( + """ + phase=loadItem.data idx=\(diagIndex, privacy: .public) \ + bytes=\(itemData.count, privacy: .public) + """ + ) Task { do { let url = try await self.viewModel.convertNSDataToURL(data: itemData) continuation.resume(returning: url) } catch { + diagLog.error( + """ + phase=loadItem.dataConvertFailed \ + idx=\(diagIndex, privacy: .public) bytes=\(itemData.count, privacy: .public) + """ + ) continuation.resume(throwing: FileImportError.dataConversionFailed) } } @@ -66,10 +99,21 @@ class ShareViewController: UIViewController, Sendable, Loggable { continuation.resume(returning: itemUrl) return } else { + diagLog.error( + """ + phase=loadItem.rejected idx=\(diagIndex, privacy: .public) \ + reason=unsupportedClass itemClass=\(diagItemClass, privacy: .public) + """ + ) continuation.resume(throwing: FileImportError.invalidItemData) return } } else { + diagLog.error( + """ + phase=loadItem.rejected idx=\(diagIndex, privacy: .public) reason=nilItem + """ + ) continuation.resume(throwing: FileImportError.invalidItemData) return } @@ -79,6 +123,7 @@ class ShareViewController: UIViewController, Sendable, Loggable { private func extractSharedFileItems() async -> [ImportedFileItem] { guard let inputItems = extensionContext?.inputItems as? [NSExtensionItem] else { + ShareExtensionLogging.emit("phase=extract.noInputItems hasContext=\(extensionContext != nil)") return [] } @@ -86,30 +131,77 @@ class ShareViewController: UIViewController, Sendable, Loggable { var result: [ImportedFileItem] = [] + var diagIndex = -1 + let diagTotalProviders = inputItems.reduce(0) { $0 + ($1.attachments?.count ?? 0) } + ShareExtensionLogging.emit( + "phase=extract.begin items=\(inputItems.count) providers=\(diagTotalProviders) " + + "memMB=\(ShareExtensionLogging.availableMemoryMB)" + ) + for item in inputItems { if let attachments = item.attachments { - for provider in attachments where - provider.hasItemConformingToTypeIdentifier(typeIdentifier.identifier) { + for provider in attachments { + diagIndex += 1 + let diagConforms = provider.hasItemConformingToTypeIdentifier(typeIdentifier.identifier) + let suggestedAttributes = provider.suggestedName + .map(ShareExtensionLogging.nameAttributes) ?? "absent" + ShareExtensionLogging.emit( + "phase=provider.begin idx=\(diagIndex) conforms=\(diagConforms) " + + "types=\(provider.registeredTypeIdentifiers.joined(separator: "|")) " + + "hasSuggestedName=\(provider.suggestedName != nil) " + + "suggested=[\(suggestedAttributes)] " + + "memMB=\(ShareExtensionLogging.availableMemoryMB)" + ) + + guard diagConforms else { continue } + do { let url = try await loadItem( for: provider, - typeIdentifier: typeIdentifier.identifier + typeIdentifier: typeIdentifier.identifier, + diagIndex: diagIndex + ) + ShareExtensionLogging.emit( + "phase=provider.loaded idx=\(diagIndex) " + + "url=[\(ShareExtensionLogging.nameAttributes(url.lastPathComponent))] " + + "srcSize=\(ShareExtensionLogging.fileSize(of: url)) " + + "memMB=\(ShareExtensionLogging.availableMemoryMB)" ) + if let fileData = try? Data(contentsOf: url) { + ShareExtensionLogging.emit( + "phase=provider.readData idx=\(diagIndex) bytes=\(fileData.count) " + + "memMB=\(ShareExtensionLogging.availableMemoryMB)" + ) result.append(ImportedFileItem( fileUrl: url, filename: url.lastPathComponent, data: fileData, typeIdentifier: typeIdentifier )) + } else { + ShareExtensionLogging.emit("phase=provider.readDataFailed idx=\(diagIndex)") } } catch let error { + let diagReason: String + switch error { + case FileImportError.loadError: diagReason = "loadError" + case FileImportError.dataConversionFailed: diagReason = "dataConversionFailed" + case FileImportError.invalidItemData: diagReason = "invalidItemData" + default: diagReason = "\((error as NSError).domain):\((error as NSError).code)" + } + ShareExtensionLogging.emit("phase=provider.failed idx=\(diagIndex) reason=\(diagReason)") ShareViewController.logger().error("Unable to load item: \(error.localizedDescription)") } } } } + ShareExtensionLogging.emit( + "phase=extract.end produced=\(result.count) of=\(diagTotalProviders) " + + "memMB=\(ShareExtensionLogging.availableMemoryMB)" + ) + return result } } diff --git a/Extensions/FileImportShareExtension/ViewModel/ShareViewModel.swift b/Extensions/FileImportShareExtension/ViewModel/ShareViewModel.swift index 82f5a39b..e9483a97 100644 --- a/Extensions/FileImportShareExtension/ViewModel/ShareViewModel.swift +++ b/Extensions/FileImportShareExtension/ViewModel/ShareViewModel.swift @@ -27,6 +27,9 @@ class ShareViewModel: ShareViewModelProtocol, Loggable { @discardableResult func importFiles(_ items: [ImportedFileItem]) async -> Bool { ShareViewModel.logger().info("Importing files...") + ShareExtensionLogging.emit( + "phase=import.begin count=\(items.count) memMB=\(ShareExtensionLogging.availableMemoryMB)" + ) guard !items.isEmpty else { await MainActor.run { [weak self] in self?.status = .failed @@ -40,6 +43,10 @@ class ShareViewModel: ShareViewModelProtocol, Loggable { items: items ) + ShareExtensionLogging.emit( + "phase=import.end ok=\(isImported) memMB=\(ShareExtensionLogging.availableMemoryMB)" + ) + if isImported { ShareViewModel.logger().info("Files imported successfully") } else { @@ -72,6 +79,7 @@ class ShareViewModel: ShareViewModelProtocol, Loggable { let item = items[itemIndex] let imported = try await cacheFileForProvider(fileItem: item) + ShareExtensionLogging.emit("phase=item.result idx=\(itemIndex) of=\(items.count) ok=\(imported)") if imported { return try await cacheItem(itemIndex: itemIndex + 1, providerIndex: providerIndex + 1, items: items) } @@ -82,6 +90,12 @@ class ShareViewModel: ShareViewModelProtocol, Loggable { func cacheFileForProvider(fileItem: ImportedFileItem) async throws -> Bool { let typeIdentifiers = [UTType.fileURL, UTType.url, UTType.data] + ShareExtensionLogging.emit( + "phase=cache.dispatch typeId=\(fileItem.typeIdentifier.identifier) " + + "matched=\(typeIdentifiers.contains(fileItem.typeIdentifier)) " + + "scheme=\(fileItem.fileUrl.scheme ?? "nil")" + ) + if typeIdentifiers.contains(fileItem.typeIdentifier) { return await cacheFileOnUrl(fileItem.fileUrl) } @@ -101,66 +115,78 @@ class ShareViewModel: ShareViewModelProtocol, Loggable { _ itemUrl: URL, ) async -> Bool { if itemUrl.scheme == "file" { + let accessing = itemUrl.startAccessingSecurityScopedResource() + defer { + if accessing { + itemUrl.stopAccessingSecurityScopedResource() + } + } + do { - if try !resourceChecker.checkResourceIsReachable(itemUrl) { + let isReachable = try resourceChecker.checkResourceIsReachable(itemUrl) + ShareExtensionLogging.emit( + "phase=cache.reachable ok=\(isReachable) " + + "readable=\(fileManager.isReadableFile(atPath: itemUrl.resolvedPath)) " + + "src=[\(ShareExtensionLogging.nameAttributes(itemUrl.lastPathComponent))]" + ) + + guard isReachable else { throw URLError(.cannotOpenFile) } - let groupTempFolderUrl = try Directories.getSharedFolder(fileManager: fileManager) + let sourceSize = try fileSize(of: itemUrl) + guard sourceSize > 0 else { + throw URLError(.zeroByteResource) + } + let groupTempFolderUrl = try Directories.getSharedFolder(fileManager: fileManager) let filePath = groupTempFolderUrl.appending(path: itemUrl.lastPathComponent) - let inputStream = InputStream(url: itemUrl) - let outputStream = OutputStream(url: filePath, append: false) - - inputStream?.open() - outputStream?.open() - - let bufferSize = 4096 - var dataBuffer = Data(count: bufferSize) - - while inputStream?.hasBytesAvailable == true { - let bytesRead: Int? = dataBuffer - .withUnsafeMutableBytes { (rawBufferPointer: UnsafeMutableRawBufferPointer) in - guard let inputStream = inputStream else { - return nil - } - - return rawBufferPointer - .bindMemory(to: UInt8.self) - .baseAddress - .map { baseAddress in - inputStream.read(baseAddress, maxLength: bufferSize) - } - } - - if let bytesRead = bytesRead, bytesRead > 0 { - dataBuffer.count = bytesRead - dataBuffer.withUnsafeBytes { (bufferPointer: UnsafeRawBufferPointer) in - guard let outputStream = outputStream, - let baseAddress = bufferPointer.bindMemory(to: UInt8.self).baseAddress else { - return - } - - outputStream.write(baseAddress, maxLength: bytesRead) - } - } + ShareExtensionLogging.emit( + "phase=cache.dest dest=[\(ShareExtensionLogging.nameAttributes(filePath.lastPathComponent))] " + + "destExists=\(fileManager.fileExists(atPath: filePath.resolvedPath))" + ) + + if fileManager.fileExists(atPath: filePath.resolvedPath) { + try fileManager.removeItem(at: filePath) + } + + try fileManager.copyItem(at: itemUrl, to: filePath) + + let copiedSize = try fileSize(of: filePath) + guard copiedSize == sourceSize else { + try? fileManager.removeItem(at: filePath) + throw URLError(.cannotWriteToFile) } - inputStream?.close() - outputStream?.close() + ShareExtensionLogging.emit("phase=cache.done srcSize=\(sourceSize) destSize=\(copiedSize)") return true } catch { + let nsError = error as NSError + ShareExtensionLogging.emit( + "phase=cache.error errDomain=\(nsError.domain) errCode=\(nsError.code) " + + "userInfoKeys=\(nsError.userInfo.keys.sorted().joined(separator: ","))" + ) ShareViewModel.logger().error("Unable to cache file: \(error.localizedDescription)") } } else if itemUrl.isValidURL() { return await downloadFileFromUrl(itemUrl) } + ShareExtensionLogging.emit("phase=cache.returningFalse scheme=\(itemUrl.scheme ?? "nil")") + return false } + private func fileSize(of url: URL) throws -> Int { + let attributes = try fileManager.attributesOfItem(atPath: url.resolvedPath) + guard let size = (attributes[.size] as? NSNumber)?.intValue else { + throw URLError(.cannotOpenFile) + } + return size + } + func downloadFileFromUrl(_ itemUrl: URL) async -> Bool { ShareViewModel.logger().info("Downloading file from \(itemUrl.absoluteString)") diff --git a/Extensions/FileImportShareExtensionTests/ViewModel/ShareViewModelTests.swift b/Extensions/FileImportShareExtensionTests/ViewModel/ShareViewModelTests.swift index 1503a24c..106432fa 100644 --- a/Extensions/FileImportShareExtensionTests/ViewModel/ShareViewModelTests.swift +++ b/Extensions/FileImportShareExtensionTests/ViewModel/ShareViewModelTests.swift @@ -51,7 +51,9 @@ struct ShareViewModelTests { ) mockFileManager.containerURLHandler = { _ in fileUrl } - mockFileManager.fileExistsHandler = { _ in true } + mockFileManager.fileExistsHandler = { _ in false } + mockFileManager.attributesOfItemHandler = { _ in [.size: NSNumber(value: 100)] } + mockFileManager.copyItemHandler = { _, _ in } mockUrlResourceChecker.checkResourceIsReachableHandler = { _ in true } let result = await viewModel.importFiles([fileItem, fileItem2]) @@ -118,7 +120,9 @@ struct ShareViewModelTests { ) mockFileManager.containerURLHandler = { _ in fileUrl } - mockFileManager.fileExistsHandler = { _ in true } + mockFileManager.fileExistsHandler = { _ in false } + mockFileManager.attributesOfItemHandler = { _ in [.size: NSNumber(value: 100)] } + mockFileManager.copyItemHandler = { _, _ in } mockUrlResourceChecker.checkResourceIsReachableHandler = { _ in true } let result = try await viewModel.cacheItem( @@ -162,7 +166,9 @@ struct ShareViewModelTests { ) mockFileManager.containerURLHandler = { _ in fileItem.fileUrl } - mockFileManager.fileExistsHandler = { _ in true } + mockFileManager.fileExistsHandler = { _ in false } + mockFileManager.attributesOfItemHandler = { _ in [.size: NSNumber(value: 100)] } + mockFileManager.copyItemHandler = { _, _ in } mockUrlResourceChecker.checkResourceIsReachableHandler = { _ in true } let result = try await viewModel.cacheFileForProvider(fileItem: fileItem) @@ -180,12 +186,15 @@ struct ShareViewModelTests { ) mockFileManager.containerURLHandler = { _ in fileItem.fileUrl } - mockFileManager.fileExistsHandler = { _ in true } + mockFileManager.fileExistsHandler = { _ in false } + mockFileManager.attributesOfItemHandler = { _ in [.size: NSNumber(value: 100)] } + mockFileManager.copyItemHandler = { _, _ in } mockUrlResourceChecker.checkResourceIsReachableHandler = { _ in true } let result = await viewModel.cacheFileOnUrl(fileItem.fileUrl) #expect(result) + #expect(mockFileManager.copyItemCallCount == 1) } @Test(.enabled(if: isLiveWebsiteTestsEnabled)) @@ -261,4 +270,35 @@ struct ShareViewModelTests { extensionItem.attachments = [provider].compactMap { $0 } return extensionItem } + + @Test + func cacheFileOnUrl_returnsFalseWhenNoBytesWereCopied() async throws { + let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + let sharedFolder = root.appending(path: "Temp") + try FileManager.default.createDirectory(at: sharedFolder, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let missingSource = root.appending(path: "vanished.txt") + + mockFileManager.containerURLHandler = { _ in root } + mockFileManager.fileExistsHandler = { path in FileManager.default.fileExists(atPath: path) } + mockFileManager.attributesOfItemHandler = { path in + try FileManager.default.attributesOfItem(atPath: path) + } + mockFileManager.copyItemHandler = { from, to in + try FileManager.default.copyItem(at: from, to: to) + } + mockUrlResourceChecker.checkResourceIsReachableHandler = { _ in true } + + let result = await viewModel.cacheFileOnUrl(missingSource) + + #expect(!result) + + let written = try? FileManager.default.contentsOfDirectory( + at: sharedFolder, + includingPropertiesForKeys: nil, + options: [] + ) + #expect(written?.isEmpty ?? true) + } } diff --git a/RIADigiDoc.xcodeproj/project.pbxproj b/RIADigiDoc.xcodeproj/project.pbxproj index 62b23f1b..bb3cc754 100644 --- a/RIADigiDoc.xcodeproj/project.pbxproj +++ b/RIADigiDoc.xcodeproj/project.pbxproj @@ -379,6 +379,7 @@ isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( DI/FileImportShareExtensionContainer.swift, + Diagnostics/ShareExtensionLogging.swift, Error/FileImportError.swift, Model/ImportedFileItem.swift, Model/Status.swift, diff --git a/RIADigiDoc/Domain/Service/FileOpening/FileOpeningService.swift b/RIADigiDoc/Domain/Service/FileOpening/FileOpeningService.swift index b0054067..71d8fd01 100644 --- a/RIADigiDoc/Domain/Service/FileOpening/FileOpeningService.swift +++ b/RIADigiDoc/Domain/Service/FileOpening/FileOpeningService.swift @@ -8,7 +8,7 @@ import CommonsLib import UtilsLib import CryptoSwift -actor FileOpeningService: FileOpeningServiceProtocol { +actor FileOpeningService: FileOpeningServiceProtocol, Loggable { private let fileUtil: FileUtilProtocol private let fileInspector: FileInspectorProtocol @@ -34,19 +34,51 @@ actor FileOpeningService: FileOpeningServiceProtocol { switch result { case .success(let urls): var validFiles: [URL] = [] + var firstError: Error? - for url in urls { + for (index, url) in urls.enumerated() { _ = url.startAccessingSecurityScopedResource() - let validUrl = try await url.validURL(fileUtil: fileUtil) - - defer { - url.stopAccessingSecurityScopedResource() + do { + let validUrl = try await url.validURL(fileUtil: fileUtil) + + defer { + url.stopAccessingSecurityScopedResource() + } + + let sizeOk = try await isFileSizeValid(url: validUrl) + FileOpeningService.logger().info( + """ + phase=app.validate idx=\(index, privacy: .public) \ + of=\(urls.count, privacy: .public) \ + ext=\(validUrl.pathExtension.lowercased(), privacy: .public) \ + dotLead=\(validUrl.lastPathComponent.hasPrefix("."), privacy: .public) \ + sizeOk=\(sizeOk, privacy: .public) + """ + ) + + if sizeOk { + await validFiles.append(try cacheFile(from: validUrl)) + } + } catch { + let nsError = error as NSError + FileOpeningService.logger().error( + """ + phase=app.validateFailed idx=\(index, privacy: .public) \ + of=\(urls.count, privacy: .public) \ + errDomain=\(nsError.domain, privacy: .public) \ + errCode=\(nsError.code, privacy: .public) \ + userInfoKeys=\(nsError.userInfo.keys.sorted().joined(separator: ","), privacy: .public) + """ + ) + if firstError == nil { + firstError = error + } } + } - if try await isFileSizeValid(url: validUrl) { - await validFiles.append(try cacheFile(from: validUrl)) - } + if validFiles.isEmpty, let firstError { + throw firstError } return validFiles diff --git a/RIADigiDoc/ViewModel/HomeViewModel.swift b/RIADigiDoc/ViewModel/HomeViewModel.swift index fc6954df..9d2ebedd 100644 --- a/RIADigiDoc/ViewModel/HomeViewModel.swift +++ b/RIADigiDoc/ViewModel/HomeViewModel.swift @@ -66,7 +66,30 @@ class HomeViewModel: HomeViewModelProtocol, Loggable { let contents = try fileManager.contentsOfDirectory( at: sharedFolderURL, includingPropertiesForKeys: nil, - options: .skipsHiddenFiles) + options: []) + + let allContents = (try? fileManager.contentsOfDirectory( + at: sharedFolderURL, + includingPropertiesForKeys: nil, + options: [])) ?? [] + HomeViewModel.logger().info( + """ + phase=app.sharedFiles visible=\(contents.count, privacy: .public) \ + all=\(allContents.count, privacy: .public) \ + hidden=\(allContents.count - contents.count, privacy: .public) + """ + ) + for url in allContents { + let name = url.lastPathComponent + HomeViewModel.logger().info( + """ + phase=app.sharedFile nameLen=\(name.count, privacy: .public) \ + ext=\(url.pathExtension.lowercased(), privacy: .public) \ + dotLead=\(name.hasPrefix("."), privacy: .public) \ + size=\((try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? -1, privacy: .public) + """ + ) + } if contents.isEmpty { HomeViewModel.logger().info("Shared files folder is empty") diff --git a/RIADigiDocTests/Domain/Service/FileOpening/FileOpeningServiceTests.swift b/RIADigiDocTests/Domain/Service/FileOpening/FileOpeningServiceTests.swift index 79ec9100..12e001e2 100644 --- a/RIADigiDocTests/Domain/Service/FileOpening/FileOpeningServiceTests.swift +++ b/RIADigiDocTests/Domain/Service/FileOpening/FileOpeningServiceTests.swift @@ -176,4 +176,27 @@ struct FileOpeningServiceTests { } } } + + @Test + func getValidFiles_keepsRemainingFilesWhenOneFileIsEmpty() async throws { + let tempURL = URL(fileURLWithPath: mockFileManager.temporaryDirectory.appending(path: "tmp").resolvedPath) + let emptyFileURL = tempURL.appending(path: "empty.txt") + let goodFileURL = tempURL.appending(path: "good.txt") + + let result: Result<[URL], Error> = .success([emptyFileURL, goodFileURL]) + + mockFileUtil.getValidPathHandler = { url in url } + mockFileManager.urlsHandler = { _, _ in [tempURL] } + mockFileManager.contentsOfDirectoryAtHandler = { _, _, _ in [emptyFileURL, goodFileURL] } + mockFileInspector.fileSizeHandler = { url in + if url.lastPathComponent == "empty.txt" { + throw FileOpeningError.invalidFileSize + } + return 100 + } + + let validFiles = try await service.getValidFiles(result) + + #expect(validFiles.map(\.lastPathComponent) == ["good.txt"]) + } } diff --git a/RIADigiDocTests/ViewModel/HomeViewModelTests.swift b/RIADigiDocTests/ViewModel/HomeViewModelTests.swift index aa3a9414..588fd3cc 100644 --- a/RIADigiDocTests/ViewModel/HomeViewModelTests.swift +++ b/RIADigiDocTests/ViewModel/HomeViewModelTests.swift @@ -118,4 +118,63 @@ struct HomeViewModelTests { return } } + + @Test + func getSharedFiles_returnsDotNamedFilesSoTheyAreNotSilentlyDropped() async throws { + let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + let sharedFolder = root.appending(path: "Temp") + try FileManager.default.createDirectory(at: sharedFolder, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let dotNamed = sharedFolder.appending(path: ".txt") + let regular = sharedFolder.appending(path: "report.txt") + try Data("shared".utf8).write(to: dotNamed) + try Data("shared".utf8).write(to: regular) + + mockFileManager.containerURLHandler = { _ in root } + mockFileManager.fileExistsHandler = { _ in true } + mockFileUtil.getValidPathHandler = { url in url } + mockFileManager.contentsOfDirectoryAtHandler = { url, keys, mask in + try FileManager.default.contentsOfDirectory( + at: url, + includingPropertiesForKeys: keys, + options: mask + ) + } + + let shared = await viewModel.getSharedFiles() + + #expect(shared.map(\.lastPathComponent).sorted() == [".txt", "report.txt"]) + } + + @Test + func getSharedFiles_listsEverythingThatRemoveSharedFilesWouldDelete() async throws { + let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + let sharedFolder = root.appending(path: "Temp") + try FileManager.default.createDirectory(at: sharedFolder, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + try Data("shared".utf8).write(to: sharedFolder.appending(path: ".txt")) + + mockFileManager.containerURLHandler = { _ in root } + mockFileManager.fileExistsHandler = { _ in true } + mockFileUtil.getValidPathHandler = { url in url } + mockFileManager.contentsOfDirectoryAtHandler = { url, keys, mask in + try FileManager.default.contentsOfDirectory( + at: url, + includingPropertiesForKeys: keys, + options: mask + ) + } + + let shared = await viewModel.getSharedFiles() + + let deletable = try FileManager.default.contentsOfDirectory( + at: sharedFolder, + includingPropertiesForKeys: nil, + options: [] + ) + + #expect(shared.count == deletable.count) + } } From f6a836ccf8a612bfcf8b7ea84cb8e01cefe284e0 Mon Sep 17 00:00:00 2001 From: Marten Rebane Date: Thu, 24 Sep 2026 18:08:40 +0300 Subject: [PATCH 2/2] Fixes for opening txt files that are added from outside the app --- .../Diagnostics/ShareExtensionLogging.swift | 28 +--- .../UI/ShareViewController.swift | 120 +++++------------- .../ViewModel/ShareViewModel.swift | 64 ++-------- .../ViewModel/ShareViewModelTests.swift | 4 +- .../Sources/UtilsLib/File/FileUtil.swift | 24 +++- .../UtilsLibTests/File/FileUtilTests.swift | 32 +++++ .../FileOpening/FileOpeningService.swift | 20 +-- .../Shared/Preview/TextFilePreview.swift | 78 ++++++++---- RIADigiDoc/UI/Theme/FontTypography.swift | 32 +++++ RIADigiDoc/ViewModel/HomeViewModel.swift | 23 ---- RIADigiDoc/ViewModel/SigningViewModel.swift | 18 +++ .../ViewModel/SigningViewModelTests.swift | 8 ++ 12 files changed, 221 insertions(+), 230 deletions(-) diff --git a/Extensions/FileImportShareExtension/Diagnostics/ShareExtensionLogging.swift b/Extensions/FileImportShareExtension/Diagnostics/ShareExtensionLogging.swift index af0b083d..a6feea1a 100644 --- a/Extensions/FileImportShareExtension/Diagnostics/ShareExtensionLogging.swift +++ b/Extensions/FileImportShareExtension/Diagnostics/ShareExtensionLogging.swift @@ -10,33 +10,11 @@ enum ShareExtensionLogging { private static let log = Logger(subsystem: subsystem, category: category) - static func emit(_ message: String) { + static func info(_ message: String) { log.info("\(message, privacy: .public)") } - static func nameAttributes(_ name: String) -> String { - let ext = (name as NSString).pathExtension.lowercased() - return "nameLen=\(name.count) ext=\(ext.isEmpty ? "none" : ext) dotLead=\(name.hasPrefix("."))" - } - - static var availableMemoryMB: Int { - #if os(iOS) - os_proc_available_memory() / (1024 * 1024) - #else - -1 - #endif - } - - static func fileSize(of url: URL) -> Int { - let attributes = try? FileManager.default.attributesOfItem(atPath: url.path) - return (attributes?[.size] as? NSNumber)?.intValue ?? -1 - } - - static func streamState(_ label: String, _ stream: Stream?) -> String { - guard let stream else { - return "\(label)Nil=true" - } - let errorCode = (stream.streamError as NSError?)?.code ?? 0 - return "\(label)Nil=false \(label)Status=\(stream.streamStatus.rawValue) \(label)Err=\(errorCode)" + static func error(_ message: String) { + log.error("\(message, privacy: .public)") } } diff --git a/Extensions/FileImportShareExtension/UI/ShareViewController.swift b/Extensions/FileImportShareExtension/UI/ShareViewController.swift index 002ebf50..6295a4be 100644 --- a/Extensions/FileImportShareExtension/UI/ShareViewController.swift +++ b/Extensions/FileImportShareExtension/UI/ShareViewController.swift @@ -45,53 +45,31 @@ class ShareViewController: UIViewController, Sendable, Loggable { } @MainActor - func loadItem(for provider: NSItemProvider, typeIdentifier: String, diagIndex: Int = -1) async throws -> URL { - let diagStart = ContinuousClock.now - ShareExtensionLogging.emit("phase=loadItem.request idx=\(diagIndex) type=\(typeIdentifier)") - + func loadItem(for provider: NSItemProvider, typeIdentifier: String) async throws -> URL { return try await withCheckedThrowingContinuation { continuation in provider.loadItem(forTypeIdentifier: typeIdentifier, options: nil) { item, error in - let diagLog = Logger(subsystem: "ee.ria.digidoc.shareext.logging", category: "import") - let diagComponents = diagStart.duration(to: .now).components - let diagElapsedMs = diagComponents.seconds * 1000 - + diagComponents.attoseconds / 1_000_000_000_000_000 - let diagError = error as NSError? - let diagItemClass = item.map { String(describing: type(of: $0)) } ?? "nil" - diagLog.info( - """ - phase=loadItem.callback idx=\(diagIndex, privacy: .public) \ - elapsedMs=\(diagElapsedMs, privacy: .public) \ - itemClass=\(diagItemClass, privacy: .public) \ - hasError=\(error != nil, privacy: .public) \ - errDomain=\(diagError?.domain ?? "none", privacy: .public) \ - errCode=\(diagError?.code ?? 0, privacy: .public) - """ - ) + let log = Logger(subsystem: "ee.ria.digidoc.shareext.logging", category: "import") if let error = error { + let nsError = error as NSError + log.error( + """ + Unable to load shared item: \(nsError.domain, privacy: .public) \ + \(nsError.code, privacy: .public) + """ + ) continuation .resume( throwing: FileImportError.loadError(description: error.localizedDescription) ) } else if item != nil { if let itemData = item as? Data { - diagLog.info( - """ - phase=loadItem.data idx=\(diagIndex, privacy: .public) \ - bytes=\(itemData.count, privacy: .public) - """ - ) Task { do { let url = try await self.viewModel.convertNSDataToURL(data: itemData) continuation.resume(returning: url) } catch { - diagLog.error( - """ - phase=loadItem.dataConvertFailed \ - idx=\(diagIndex, privacy: .public) bytes=\(itemData.count, privacy: .public) - """ - ) + log.error("Unable to write shared item data to a temporary file") continuation.resume(throwing: FileImportError.dataConversionFailed) } } @@ -99,21 +77,17 @@ class ShareViewController: UIViewController, Sendable, Loggable { continuation.resume(returning: itemUrl) return } else { - diagLog.error( + log.error( """ - phase=loadItem.rejected idx=\(diagIndex, privacy: .public) \ - reason=unsupportedClass itemClass=\(diagItemClass, privacy: .public) + Shared item is not a file or data: \ + \(item.map { String(describing: type(of: $0)) } ?? "unknown", privacy: .public) """ ) continuation.resume(throwing: FileImportError.invalidItemData) return } } else { - diagLog.error( - """ - phase=loadItem.rejected idx=\(diagIndex, privacy: .public) reason=nilItem - """ - ) + log.error("Shared item is empty") continuation.resume(throwing: FileImportError.invalidItemData) return } @@ -123,56 +97,37 @@ class ShareViewController: UIViewController, Sendable, Loggable { private func extractSharedFileItems() async -> [ImportedFileItem] { guard let inputItems = extensionContext?.inputItems as? [NSExtensionItem] else { - ShareExtensionLogging.emit("phase=extract.noInputItems hasContext=\(extensionContext != nil)") + ShareExtensionLogging.error("Share extension received no input items") return [] } let typeIdentifier = UTType.data var result: [ImportedFileItem] = [] + let totalProviders = inputItems.reduce(0) { $0 + ($1.attachments?.count ?? 0) } - var diagIndex = -1 - let diagTotalProviders = inputItems.reduce(0) { $0 + ($1.attachments?.count ?? 0) } - ShareExtensionLogging.emit( - "phase=extract.begin items=\(inputItems.count) providers=\(diagTotalProviders) " - + "memMB=\(ShareExtensionLogging.availableMemoryMB)" - ) + ShareExtensionLogging.info("Importing \(totalProviders) shared item(s)") for item in inputItems { if let attachments = item.attachments { for provider in attachments { - diagIndex += 1 - let diagConforms = provider.hasItemConformingToTypeIdentifier(typeIdentifier.identifier) - let suggestedAttributes = provider.suggestedName - .map(ShareExtensionLogging.nameAttributes) ?? "absent" - ShareExtensionLogging.emit( - "phase=provider.begin idx=\(diagIndex) conforms=\(diagConforms) " - + "types=\(provider.registeredTypeIdentifiers.joined(separator: "|")) " - + "hasSuggestedName=\(provider.suggestedName != nil) " - + "suggested=[\(suggestedAttributes)] " - + "memMB=\(ShareExtensionLogging.availableMemoryMB)" - ) - - guard diagConforms else { continue } + guard provider.hasItemConformingToTypeIdentifier(typeIdentifier.identifier) else { + ShareExtensionLogging.error( + """ + Skipping shared item of unsupported type: \ + \(provider.registeredTypeIdentifiers.joined(separator: ",")) + """ + ) + continue + } do { let url = try await loadItem( for: provider, - typeIdentifier: typeIdentifier.identifier, - diagIndex: diagIndex - ) - ShareExtensionLogging.emit( - "phase=provider.loaded idx=\(diagIndex) " - + "url=[\(ShareExtensionLogging.nameAttributes(url.lastPathComponent))] " - + "srcSize=\(ShareExtensionLogging.fileSize(of: url)) " - + "memMB=\(ShareExtensionLogging.availableMemoryMB)" + typeIdentifier: typeIdentifier.identifier ) if let fileData = try? Data(contentsOf: url) { - ShareExtensionLogging.emit( - "phase=provider.readData idx=\(diagIndex) bytes=\(fileData.count) " - + "memMB=\(ShareExtensionLogging.availableMemoryMB)" - ) result.append(ImportedFileItem( fileUrl: url, filename: url.lastPathComponent, @@ -180,27 +135,20 @@ class ShareViewController: UIViewController, Sendable, Loggable { typeIdentifier: typeIdentifier )) } else { - ShareExtensionLogging.emit("phase=provider.readDataFailed idx=\(diagIndex)") + ShareExtensionLogging.error("Unable to read the loaded shared item") } } catch let error { - let diagReason: String - switch error { - case FileImportError.loadError: diagReason = "loadError" - case FileImportError.dataConversionFailed: diagReason = "dataConversionFailed" - case FileImportError.invalidItemData: diagReason = "invalidItemData" - default: diagReason = "\((error as NSError).domain):\((error as NSError).code)" - } - ShareExtensionLogging.emit("phase=provider.failed idx=\(diagIndex) reason=\(diagReason)") - ShareViewController.logger().error("Unable to load item: \(error.localizedDescription)") + ShareExtensionLogging.error( + "Unable to load shared item: \(String(describing: type(of: error)))" + ) } } } } - ShareExtensionLogging.emit( - "phase=extract.end produced=\(result.count) of=\(diagTotalProviders) " - + "memMB=\(ShareExtensionLogging.availableMemoryMB)" - ) + if result.count != totalProviders { + ShareExtensionLogging.error("Prepared \(result.count) of \(totalProviders) shared item(s)") + } return result } diff --git a/Extensions/FileImportShareExtension/ViewModel/ShareViewModel.swift b/Extensions/FileImportShareExtension/ViewModel/ShareViewModel.swift index e9483a97..d89af26a 100644 --- a/Extensions/FileImportShareExtension/ViewModel/ShareViewModel.swift +++ b/Extensions/FileImportShareExtension/ViewModel/ShareViewModel.swift @@ -26,10 +26,6 @@ class ShareViewModel: ShareViewModelProtocol, Loggable { @discardableResult func importFiles(_ items: [ImportedFileItem]) async -> Bool { - ShareViewModel.logger().info("Importing files...") - ShareExtensionLogging.emit( - "phase=import.begin count=\(items.count) memMB=\(ShareExtensionLogging.availableMemoryMB)" - ) guard !items.isEmpty else { await MainActor.run { [weak self] in self?.status = .failed @@ -43,14 +39,10 @@ class ShareViewModel: ShareViewModelProtocol, Loggable { items: items ) - ShareExtensionLogging.emit( - "phase=import.end ok=\(isImported) memMB=\(ShareExtensionLogging.availableMemoryMB)" - ) - if isImported { - ShareViewModel.logger().info("Files imported successfully") + ShareExtensionLogging.info("Imported \(items.count) shared file(s)") } else { - ShareViewModel.logger().error("Could not import files") + ShareExtensionLogging.error("Unable to import all \(items.count) shared file(s)") } await MainActor.run { [weak self] in @@ -59,7 +51,9 @@ class ShareViewModel: ShareViewModelProtocol, Loggable { return isImported } catch { - ShareViewModel.logger().error("Unable to import files: \(error.localizedDescription)") + ShareExtensionLogging.error( + "Unable to import shared files: \((error as NSError).domain) \((error as NSError).code)" + ) await MainActor.run { [weak self] in self?.status = .failed } @@ -79,7 +73,6 @@ class ShareViewModel: ShareViewModelProtocol, Loggable { let item = items[itemIndex] let imported = try await cacheFileForProvider(fileItem: item) - ShareExtensionLogging.emit("phase=item.result idx=\(itemIndex) of=\(items.count) ok=\(imported)") if imported { return try await cacheItem(itemIndex: itemIndex + 1, providerIndex: providerIndex + 1, items: items) } @@ -90,12 +83,6 @@ class ShareViewModel: ShareViewModelProtocol, Loggable { func cacheFileForProvider(fileItem: ImportedFileItem) async throws -> Bool { let typeIdentifiers = [UTType.fileURL, UTType.url, UTType.data] - ShareExtensionLogging.emit( - "phase=cache.dispatch typeId=\(fileItem.typeIdentifier.identifier) " - + "matched=\(typeIdentifiers.contains(fileItem.typeIdentifier)) " - + "scheme=\(fileItem.fileUrl.scheme ?? "nil")" - ) - if typeIdentifiers.contains(fileItem.typeIdentifier) { return await cacheFileOnUrl(fileItem.fileUrl) } @@ -124,12 +111,6 @@ class ShareViewModel: ShareViewModelProtocol, Loggable { do { let isReachable = try resourceChecker.checkResourceIsReachable(itemUrl) - ShareExtensionLogging.emit( - "phase=cache.reachable ok=\(isReachable) " - + "readable=\(fileManager.isReadableFile(atPath: itemUrl.resolvedPath)) " - + "src=[\(ShareExtensionLogging.nameAttributes(itemUrl.lastPathComponent))]" - ) - guard isReachable else { throw URLError(.cannotOpenFile) } @@ -142,11 +123,6 @@ class ShareViewModel: ShareViewModelProtocol, Loggable { let groupTempFolderUrl = try Directories.getSharedFolder(fileManager: fileManager) let filePath = groupTempFolderUrl.appending(path: itemUrl.lastPathComponent) - ShareExtensionLogging.emit( - "phase=cache.dest dest=[\(ShareExtensionLogging.nameAttributes(filePath.lastPathComponent))] " - + "destExists=\(fileManager.fileExists(atPath: filePath.resolvedPath))" - ) - if fileManager.fileExists(atPath: filePath.resolvedPath) { try fileManager.removeItem(at: filePath) } @@ -159,23 +135,19 @@ class ShareViewModel: ShareViewModelProtocol, Loggable { throw URLError(.cannotWriteToFile) } - ShareExtensionLogging.emit("phase=cache.done srcSize=\(sourceSize) destSize=\(copiedSize)") - return true } catch { let nsError = error as NSError - ShareExtensionLogging.emit( - "phase=cache.error errDomain=\(nsError.domain) errCode=\(nsError.code) " - + "userInfoKeys=\(nsError.userInfo.keys.sorted().joined(separator: ","))" + ShareExtensionLogging.error( + "Unable to copy shared file: \(nsError.domain) \(nsError.code)" ) - ShareViewModel.logger().error("Unable to cache file: \(error.localizedDescription)") } } else if itemUrl.isValidURL() { return await downloadFileFromUrl(itemUrl) + } else { + ShareExtensionLogging.error("Shared item has an unsupported scheme: \(itemUrl.scheme ?? "none")") } - ShareExtensionLogging.emit("phase=cache.returningFalse scheme=\(itemUrl.scheme ?? "nil")") - return false } @@ -188,7 +160,7 @@ class ShareViewModel: ShareViewModelProtocol, Loggable { } func downloadFileFromUrl(_ itemUrl: URL) async -> Bool { - ShareViewModel.logger().info("Downloading file from \(itemUrl.absoluteString)") + ShareExtensionLogging.info("Downloading shared file") do { let destinationURL = try Directories.getTempDirectory( @@ -204,25 +176,13 @@ class ShareViewModel: ShareViewModelProtocol, Loggable { let request = AF.download(itemUrl, to: destination) - Task { - for await progress in request.downloadProgress() { - let fileName = itemUrl.lastPathComponent - let downloadProgress = progress.fractionCompleted * 100 - ShareViewModel.logger().info( - "\(String(format: "Download progress for file '%@': %.2f%%", fileName, downloadProgress))" - ) - } - } - let downloadTask = request.serializingDownloadedFileURL() let fileURL = try await downloadTask.value return await cacheFileOnUrl(fileURL) } catch let error { - let errorDescription = error.localizedDescription - ShareViewModel.logger().error( - "\(String(format: "Unable to download file %@: %@", itemUrl.absoluteString, errorDescription))" - ) + let nsError = error as NSError + ShareExtensionLogging.error("Unable to download shared file: \(nsError.domain) \(nsError.code)") return false } } diff --git a/Extensions/FileImportShareExtensionTests/ViewModel/ShareViewModelTests.swift b/Extensions/FileImportShareExtensionTests/ViewModel/ShareViewModelTests.swift index 106432fa..bd4b2b25 100644 --- a/Extensions/FileImportShareExtensionTests/ViewModel/ShareViewModelTests.swift +++ b/Extensions/FileImportShareExtensionTests/ViewModel/ShareViewModelTests.swift @@ -285,8 +285,8 @@ struct ShareViewModelTests { mockFileManager.attributesOfItemHandler = { path in try FileManager.default.attributesOfItem(atPath: path) } - mockFileManager.copyItemHandler = { from, to in - try FileManager.default.copyItem(at: from, to: to) + mockFileManager.copyItemHandler = { source, destination in + try FileManager.default.copyItem(at: source, to: destination) } mockUrlResourceChecker.checkResourceIsReachableHandler = { _ in true } diff --git a/Modules/UtilsLib/Sources/UtilsLib/File/FileUtil.swift b/Modules/UtilsLib/Sources/UtilsLib/File/FileUtil.swift index c077d7e9..0ba9abc0 100644 --- a/Modules/UtilsLib/Sources/UtilsLib/File/FileUtil.swift +++ b/Modules/UtilsLib/Sources/UtilsLib/File/FileUtil.swift @@ -109,7 +109,11 @@ public struct FileUtil: FileUtilProtocol, Loggable { options: .withoutChanges, error: &error ) { coordURL in - continuation.resume(returning: coordURL) + do { + continuation.resume(returning: try copyCoordinatedFile(from: coordURL)) + } catch let copyError { + continuation.resume(throwing: copyError) + } } if let error { @@ -131,6 +135,24 @@ public struct FileUtil: FileUtilProtocol, Loggable { return nil } + func copyCoordinatedFile(from coordinatedURL: URL) throws -> URL { + let destinationDirectory = try Directories.getTempDirectory( + subfolder: CommonsLib.Constants.Folder.Temp, + fileManager: fileManager + ).appending(path: UUID().uuidString, directoryHint: .isDirectory) + + try fileManager.createDirectory( + at: destinationDirectory, + withIntermediateDirectories: true, + attributes: nil + ) + + let destination = destinationDirectory.appending(path: coordinatedURL.lastPathComponent) + try fileManager.copyItem(at: coordinatedURL, to: destination) + + return destination + } + public func getFileUrlFromAppGroup(_ url: URL, appGroupIdentifier: String) -> URL? { let resolvedURL = url.resolvingSymlinksInPath() let filePath = FilePath(resolvedURL.resolvedPath).lexicallyNormalized() diff --git a/Modules/UtilsLib/Tests/UtilsLibTests/File/FileUtilTests.swift b/Modules/UtilsLib/Tests/UtilsLibTests/File/FileUtilTests.swift index 8ecfe5e3..98198459 100644 --- a/Modules/UtilsLib/Tests/UtilsLibTests/File/FileUtilTests.swift +++ b/Modules/UtilsLib/Tests/UtilsLibTests/File/FileUtilTests.swift @@ -349,4 +349,36 @@ struct FileUtilTests { #expect(mockFileManager.removeItemCallCount == 1) } + + @Test + func copyCoordinatedFile_returnsLocalCopyWithIdenticalContents() async throws { + let realFileManager = FileManager.default + let sandbox = realFileManager.temporaryDirectory.appending(path: UUID().uuidString) + try realFileManager.createDirectory(at: sandbox, withIntermediateDirectories: true) + defer { try? realFileManager.removeItem(at: sandbox) } + + let source = sandbox.appending(path: "notes.txt") + let contents = Data("coordinated contents".utf8) + try contents.write(to: source) + + mockFileManager.temporaryDirectory = sandbox + mockFileManager.fileExistsHandler = { FileManager.default.fileExists(atPath: $0) } + mockFileManager.createDirectoryHandler = { url, intermediates, attributes in + try FileManager.default.createDirectory( + at: url, + withIntermediateDirectories: intermediates, + attributes: attributes + ) + } + mockFileManager.copyItemHandler = { source, destination in + try FileManager.default.copyItem(at: source, to: destination) + } + + let concreteFileUtil = FileUtil(fileManager: mockFileManager) + let copied = try concreteFileUtil.copyCoordinatedFile(from: source) + + #expect(copied != source) + #expect(copied.lastPathComponent == "notes.txt") + #expect(try Data(contentsOf: copied) == contents) + } } diff --git a/RIADigiDoc/Domain/Service/FileOpening/FileOpeningService.swift b/RIADigiDoc/Domain/Service/FileOpening/FileOpeningService.swift index 71d8fd01..4f9b9df7 100644 --- a/RIADigiDoc/Domain/Service/FileOpening/FileOpeningService.swift +++ b/RIADigiDoc/Domain/Service/FileOpening/FileOpeningService.swift @@ -46,29 +46,15 @@ actor FileOpeningService: FileOpeningServiceProtocol, Loggable { url.stopAccessingSecurityScopedResource() } - let sizeOk = try await isFileSizeValid(url: validUrl) - FileOpeningService.logger().info( - """ - phase=app.validate idx=\(index, privacy: .public) \ - of=\(urls.count, privacy: .public) \ - ext=\(validUrl.pathExtension.lowercased(), privacy: .public) \ - dotLead=\(validUrl.lastPathComponent.hasPrefix("."), privacy: .public) \ - sizeOk=\(sizeOk, privacy: .public) - """ - ) - - if sizeOk { + if try await isFileSizeValid(url: validUrl) { await validFiles.append(try cacheFile(from: validUrl)) } } catch { let nsError = error as NSError FileOpeningService.logger().error( """ - phase=app.validateFailed idx=\(index, privacy: .public) \ - of=\(urls.count, privacy: .public) \ - errDomain=\(nsError.domain, privacy: .public) \ - errCode=\(nsError.code, privacy: .public) \ - userInfoKeys=\(nsError.userInfo.keys.sorted().joined(separator: ","), privacy: .public) + Skipping file \(index + 1, privacy: .public) of \(urls.count, privacy: .public): \ + \(nsError.domain, privacy: .public) \(nsError.code, privacy: .public) """ ) if firstError == nil { diff --git a/RIADigiDoc/UI/Component/Shared/Preview/TextFilePreview.swift b/RIADigiDoc/UI/Component/Shared/Preview/TextFilePreview.swift index 45a79e0f..7006a668 100644 --- a/RIADigiDoc/UI/Component/Shared/Preview/TextFilePreview.swift +++ b/RIADigiDoc/UI/Component/Shared/Preview/TextFilePreview.swift @@ -2,6 +2,7 @@ // SPDX-License-Identifier: LGPL-2.1-or-later import SwiftUI +import UIKit struct TextFilePreview: View { @Environment(LanguageSettings.self) private var languageSettings @@ -15,30 +16,7 @@ struct TextFilePreview: View { @State private var showError: Bool = false var body: some View { - ZStack(alignment: .topTrailing) { - Group { - if showError { - ContentUnavailableView { - Text( - verbatim: languageSettings.localized( - "Failed to open file", [url.lastPathComponent] - ) - ) - } - .listRowSeparator(.hidden) - } else { - ScrollView { - Text(verbatim: text) - .font(typography.bodyMedium) - .foregroundStyle(theme.onSurfaceVariant) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, Dimensions.Padding.SPadding) - .padding(.vertical, Dimensions.Padding.XXLPadding) - .textSelection(.enabled) - } - } - } - + VStack(spacing: 0) { ZStack { Text(verbatim: url.lastPathComponent) .font(typography.bodyMedium) @@ -66,6 +44,30 @@ struct TextFilePreview: View { .padding(Dimensions.Padding.XSPadding) } } + + if showError { + ContentUnavailableView { + Text( + verbatim: languageSettings.localized( + "Failed to open file", [url.lastPathComponent] + ) + ) + } + .listRowSeparator(.hidden) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + SelectableTextView( + text: text, + font: FontTypography.uiFont(for: .bodyMedium), + textColor: UIColor(theme.onSurfaceVariant), + insets: UIEdgeInsets( + top: Dimensions.Padding.SPadding, + left: Dimensions.Padding.SPadding, + bottom: Dimensions.Padding.XXLPadding, + right: Dimensions.Padding.SPadding + ) + ) + } } .task { do { @@ -77,3 +79,31 @@ struct TextFilePreview: View { } } } + +private struct SelectableTextView: UIViewRepresentable { + let text: String + let font: UIFont + let textColor: UIColor + let insets: UIEdgeInsets + + func makeUIView(context _: Context) -> UITextView { + let textView = UITextView() + textView.isEditable = false + textView.isSelectable = true + textView.backgroundColor = .clear + textView.alwaysBounceVertical = true + textView.adjustsFontForContentSizeCategory = true + textView.textContainerInset = insets + textView.textContainer.lineFragmentPadding = 0 + return textView + } + + func updateUIView(_ uiView: UITextView, context _: Context) { + if uiView.text != text { + uiView.text = text + } + uiView.font = font + uiView.textColor = textColor + uiView.textContainerInset = insets + } +} diff --git a/RIADigiDoc/UI/Theme/FontTypography.swift b/RIADigiDoc/UI/Theme/FontTypography.swift index fcb91240..8c641144 100644 --- a/RIADigiDoc/UI/Theme/FontTypography.swift +++ b/RIADigiDoc/UI/Theme/FontTypography.swift @@ -2,6 +2,7 @@ // SPDX-License-Identifier: LGPL-2.1-or-later import SwiftUI +import UIKit enum AppTextStyle { case displayLarge, displayMedium, displaySmall @@ -54,6 +55,18 @@ struct FontTypography { return .custom(fontName, size: config.size, relativeTo: config.textStyle) } + static func uiFont(for style: AppTextStyle) -> UIFont { + let config = fontConfig(for: style) + let fontName = fontName(forCondensed: config.isCondensed, weight: config.weight) + + guard let font = UIFont(name: fontName, size: config.size) else { + return UIFontMetrics(forTextStyle: config.textStyle.uiTextStyle) + .scaledFont(for: .systemFont(ofSize: config.size)) + } + + return UIFontMetrics(forTextStyle: config.textStyle.uiTextStyle).scaledFont(for: font) + } + private static func fontConfig(for style: AppTextStyle) -> FontConfig { return fontConfigs[style] ?? FontConfig(size: 14, weight: .regular, isCondensed: false, textStyle: .body) } @@ -73,6 +86,25 @@ struct FontTypography { } } +extension Font.TextStyle { + var uiTextStyle: UIFont.TextStyle { + switch self { + case .largeTitle: return .largeTitle + case .title: return .title1 + case .title2: return .title2 + case .title3: return .title3 + case .headline: return .headline + case .subheadline: return .subheadline + case .body: return .body + case .callout: return .callout + case .footnote: return .footnote + case .caption: return .caption1 + case .caption2: return .caption2 + @unknown default: return .body + } + } +} + extension FontTypography { static let labelSmallSize: CGFloat = 11 static let labelMediumSize: CGFloat = 12 diff --git a/RIADigiDoc/ViewModel/HomeViewModel.swift b/RIADigiDoc/ViewModel/HomeViewModel.swift index 9d2ebedd..87a57e7b 100644 --- a/RIADigiDoc/ViewModel/HomeViewModel.swift +++ b/RIADigiDoc/ViewModel/HomeViewModel.swift @@ -68,29 +68,6 @@ class HomeViewModel: HomeViewModelProtocol, Loggable { includingPropertiesForKeys: nil, options: []) - let allContents = (try? fileManager.contentsOfDirectory( - at: sharedFolderURL, - includingPropertiesForKeys: nil, - options: [])) ?? [] - HomeViewModel.logger().info( - """ - phase=app.sharedFiles visible=\(contents.count, privacy: .public) \ - all=\(allContents.count, privacy: .public) \ - hidden=\(allContents.count - contents.count, privacy: .public) - """ - ) - for url in allContents { - let name = url.lastPathComponent - HomeViewModel.logger().info( - """ - phase=app.sharedFile nameLen=\(name.count, privacy: .public) \ - ext=\(url.pathExtension.lowercased(), privacy: .public) \ - dotLead=\(name.hasPrefix("."), privacy: .public) \ - size=\((try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? -1, privacy: .public) - """ - ) - } - if contents.isEmpty { HomeViewModel.logger().info("Shared files folder is empty") } else { diff --git a/RIADigiDoc/ViewModel/SigningViewModel.swift b/RIADigiDoc/ViewModel/SigningViewModel.swift index 5a6502e1..2af4d0a8 100644 --- a/RIADigiDoc/ViewModel/SigningViewModel.swift +++ b/RIADigiDoc/ViewModel/SigningViewModel.swift @@ -364,6 +364,24 @@ class SigningViewModel: SigningViewModelProtocol, Loggable { ) } + let extractedSize = (try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? -1 + + guard extractedSize > 0 else { + SigningViewModel.logger().error( + """ + Extracted data file is empty, container reported \ + \(dataFile.fileSize, privacy: .public) bytes + """ + ) + throw DigiDocError.containerDataFileSavingFailed( + ErrorDetail( + message: "Extracted datafile is empty", + code: 0, + userInfo: ["fileName": fileURL.lastPathComponent] + ) + ) + } + return .success(fileURL) } catch { return .failure(error) diff --git a/RIADigiDocTests/ViewModel/SigningViewModelTests.swift b/RIADigiDocTests/ViewModel/SigningViewModelTests.swift index 6b39062f..34983797 100644 --- a/RIADigiDocTests/ViewModel/SigningViewModelTests.swift +++ b/RIADigiDocTests/ViewModel/SigningViewModelTests.swift @@ -342,6 +342,7 @@ struct SigningViewModelTests: Loggable { let mockSignedContainer = SignedContainerProtocolMock() let testFile = URL(fileURLWithPath: "/tmp/test.txt") + try Data("test".utf8).write(to: testFile) let mimeType = CommonsLib.Constants.MimeType.Default @@ -456,6 +457,7 @@ struct SigningViewModelTests: Loggable { let mockSignedContainer = SignedContainerProtocolMock() let testFile = URL(fileURLWithPath: "/tmp/test.txt") + try Data("test".utf8).write(to: testFile) let mimeType = CommonsLib.Constants.MimeType.Default @@ -590,6 +592,7 @@ struct SigningViewModelTests: Loggable { let mockSignedContainer = SignedContainerProtocolMock() let testFile = URL(fileURLWithPath: "/tmp/mockSignedContainer.ddoc") + try Data("test".utf8).write(to: testFile) let mimeType = CommonsLib.Constants.MimeType.Ddoc @@ -625,6 +628,7 @@ struct SigningViewModelTests: Loggable { @Test func handleSaveFile_success() async throws { let testFile = URL(fileURLWithPath: "/tmp/test.txt") + try Data("test".utf8).write(to: testFile) let mockSignedContainer = SignedContainerProtocolMock() let testDataFile = MockDataFileWrapper.mockDataFileWrapper( @@ -646,6 +650,7 @@ struct SigningViewModelTests: Loggable { @Test func handleSaveFile_throwErrorWhenSavingDataFile() async throws { let testFile = URL(fileURLWithPath: "/tmp/test.txt") + try Data("test".utf8).write(to: testFile) let mockSignedContainer = SignedContainerProtocolMock() let testDataFile = MockDataFileWrapper.mockDataFileWrapper( @@ -682,6 +687,7 @@ struct SigningViewModelTests: Loggable { let mockSignedContainer = SignedContainerProtocolMock() let testFile = URL(fileURLWithPath: "/tmp/mockSignedContainer.asics") + try Data("test".utf8).write(to: testFile) let mimeType = CommonsLib.Constants.MimeType.Asics @@ -712,6 +718,7 @@ struct SigningViewModelTests: Loggable { let mockSignedContainer = SignedContainerProtocolMock() let testFile = URL(fileURLWithPath: "/tmp/mockSignedContainer.asice") + try Data("test".utf8).write(to: testFile) let mimeType = CommonsLib.Constants.MimeType.Asice @@ -742,6 +749,7 @@ struct SigningViewModelTests: Loggable { let mockSignedContainer = SignedContainerProtocolMock() let testFile = URL(fileURLWithPath: "/tmp/mockSignedContainer.asice") + try Data("test".utf8).write(to: testFile) let mimeType = CommonsLib.Constants.MimeType.Asice