Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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)")
}
}
46 changes: 43 additions & 3 deletions Extensions/FileImportShareExtension/UI/ShareViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import SwiftUI
import UniformTypeIdentifiers
import FactoryKit
import UtilsLib
import os

class ShareViewController: UIViewController, Sendable, Loggable {
let viewModel = Container.shared.shareViewModel()
Expand Down Expand Up @@ -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)
Expand All @@ -59,17 +69,25 @@ 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)
}
}
} else if let itemUrl = item as? URL {
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
}
Expand All @@ -79,37 +97,59 @@ 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,
filename: url.lastPathComponent,
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
}
}
104 changes: 45 additions & 59 deletions Extensions/FileImportShareExtension/ViewModel/ShareViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
}
Expand Down Expand Up @@ -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(
Expand All @@ -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
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand All @@ -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))
Expand Down Expand Up @@ -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)
}
}
Loading
Loading