diff --git a/Extensions/FileImportShareExtension/Diagnostics/ShareExtensionLogging.swift b/Extensions/FileImportShareExtension/Diagnostics/ShareExtensionLogging.swift new file mode 100644 index 00000000..a6feea1a --- /dev/null +++ b/Extensions/FileImportShareExtension/Diagnostics/ShareExtensionLogging.swift @@ -0,0 +1,20 @@ +// 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 info(_ message: String) { + log.info("\(message, privacy: .public)") + } + + 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 dfb58f95..6295a4be 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() @@ -47,7 +48,16 @@ class ShareViewController: UIViewController, Sendable, Loggable { 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 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) @@ -59,6 +69,7 @@ class ShareViewController: UIViewController, Sendable, Loggable { let url = try await self.viewModel.convertNSDataToURL(data: itemData) continuation.resume(returning: url) } catch { + log.error("Unable to write shared item data to a temporary file") continuation.resume(throwing: FileImportError.dataConversionFailed) } } @@ -66,10 +77,17 @@ class ShareViewController: UIViewController, Sendable, Loggable { continuation.resume(returning: itemUrl) return } else { + log.error( + """ + 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 { + log.error("Shared item is empty") continuation.resume(throwing: FileImportError.invalidItemData) return } @@ -79,22 +97,36 @@ class ShareViewController: UIViewController, Sendable, Loggable { private func extractSharedFileItems() async -> [ImportedFileItem] { guard let inputItems = extensionContext?.inputItems as? [NSExtensionItem] else { + 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) } + + ShareExtensionLogging.info("Importing \(totalProviders) shared item(s)") for item in inputItems { if let attachments = item.attachments { - for provider in attachments where - provider.hasItemConformingToTypeIdentifier(typeIdentifier.identifier) { + for provider in attachments { + 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 ) + if let fileData = try? Data(contentsOf: url) { result.append(ImportedFileItem( fileUrl: url, @@ -102,14 +134,22 @@ class ShareViewController: UIViewController, Sendable, Loggable { data: fileData, typeIdentifier: typeIdentifier )) + } else { + ShareExtensionLogging.error("Unable to read the loaded shared item") } } catch let error { - ShareViewController.logger().error("Unable to load item: \(error.localizedDescription)") + ShareExtensionLogging.error( + "Unable to load shared item: \(String(describing: type(of: error)))" + ) } } } } + 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 82f5a39b..d89af26a 100644 --- a/Extensions/FileImportShareExtension/ViewModel/ShareViewModel.swift +++ b/Extensions/FileImportShareExtension/ViewModel/ShareViewModel.swift @@ -26,7 +26,6 @@ class ShareViewModel: ShareViewModelProtocol, Loggable { @discardableResult func importFiles(_ items: [ImportedFileItem]) async -> Bool { - ShareViewModel.logger().info("Importing files...") guard !items.isEmpty else { await MainActor.run { [weak self] in self?.status = .failed @@ -41,9 +40,9 @@ class ShareViewModel: ShareViewModelProtocol, Loggable { ) 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 @@ -52,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 } @@ -101,68 +102,65 @@ 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) + 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) - } - } + if fileManager.fileExists(atPath: filePath.resolvedPath) { + try fileManager.removeItem(at: filePath) } - inputStream?.close() - outputStream?.close() + 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) + } return true } catch { - ShareViewModel.logger().error("Unable to cache file: \(error.localizedDescription)") + let nsError = error as NSError + ShareExtensionLogging.error( + "Unable to copy shared file: \(nsError.domain) \(nsError.code)" + ) } } else if itemUrl.isValidURL() { return await downloadFileFromUrl(itemUrl) + } else { + ShareExtensionLogging.error("Shared item has an unsupported scheme: \(itemUrl.scheme ?? "none")") } 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)") + ShareExtensionLogging.info("Downloading shared file") do { let destinationURL = try Directories.getTempDirectory( @@ -178,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 1503a24c..bd4b2b25 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 = { source, destination in + try FileManager.default.copyItem(at: source, to: destination) + } + 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/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.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..4f9b9df7 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,37 @@ 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() + } + + if try await isFileSizeValid(url: validUrl) { + await validFiles.append(try cacheFile(from: validUrl)) + } + } catch { + let nsError = error as NSError + FileOpeningService.logger().error( + """ + Skipping file \(index + 1, privacy: .public) of \(urls.count, privacy: .public): \ + \(nsError.domain, privacy: .public) \(nsError.code, 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/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 fc6954df..87a57e7b 100644 --- a/RIADigiDoc/ViewModel/HomeViewModel.swift +++ b/RIADigiDoc/ViewModel/HomeViewModel.swift @@ -66,7 +66,7 @@ class HomeViewModel: HomeViewModelProtocol, Loggable { let contents = try fileManager.contentsOfDirectory( at: sharedFolderURL, includingPropertiesForKeys: nil, - options: .skipsHiddenFiles) + options: []) if contents.isEmpty { HomeViewModel.logger().info("Shared files folder is empty") 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/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) + } } 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