diff --git a/Sources/InfomaniakCore/Asynchronous/FlowToAsyncResult.swift b/Sources/InfomaniakCore/Asynchronous/FlowToAsyncResult.swift new file mode 100644 index 0000000..2ae43dd --- /dev/null +++ b/Sources/InfomaniakCore/Asynchronous/FlowToAsyncResult.swift @@ -0,0 +1,82 @@ +/* + Infomaniak kDrive - iOS App + Copyright (C) 2023 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +import Combine +import Foundation + +/// Encapsulate a simple asynchronous event into a Combine flow in order to provide a nice swift `async Result<>`. +/// +/// Useful when dealing with old xOS APIs that do not work well with swift native structured concurrency. +@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +public final class FlowToAsyncResult { + // MARK: Private + + /// Internal observation of the Combine progress Pipe + private var flowObserver: AnyCancellable? + + /// Internal Task that wraps the combine result observation + private lazy var resultTask: Task = Task { + let result: Success = try await withCheckedThrowingContinuation { continuation in + self.flowObserver = flow.sink { result in + switch result { + case .finished: + break + case .failure(let error): + continuation.resume(throwing: error) + } + self.flowObserver?.cancel() + } receiveValue: { value in + continuation.resume(with: .success(value)) + } + } + + return result + } + + // MARK: Public + + /// Track task progress with internal Combine pipe. + /// + /// Public entry point, send result threw this pipe. + public let flow = PassthroughSubject() + + /// Provides a nice `async Result` public API + public var result: Result { + get async { + return await resultTask.result + } + } + + // MARK: Init + + public init() { + // META keep SonarCloud happy + } +} + +/// Shorthand to access underlying flow +@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +public extension FlowToAsyncResult { + func send(_ input: Success) { + flow.send(input) + } + + func send(completion: Subscribers.Completion) { + flow.send(completion: completion) + } +} diff --git a/Sources/InfomaniakCore/ItemProviderRepresentation/ItemProviderFileRepresentation.swift b/Sources/InfomaniakCore/ItemProviderRepresentation/ItemProviderFileRepresentation.swift index b3984a6..9386c2b 100644 --- a/Sources/InfomaniakCore/ItemProviderRepresentation/ItemProviderFileRepresentation.swift +++ b/Sources/InfomaniakCore/ItemProviderRepresentation/ItemProviderFileRepresentation.swift @@ -18,13 +18,19 @@ #if canImport(MobileCoreServices) -import Combine import Foundation import InfomaniakDI /// Something that can provide a `Progress` and an async `Result` in order to load an url from a `NSItemProvider` @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) public final class ItemProviderFileRepresentation: NSObject, ProgressResultable { + /// Something to transform events to a nice `async Result` + private let flowToAsync = FlowToAsyncResult() + + /// Shorthand for default FileManager + private let fileManager = FileManager.default + + /// Domain specific errors public enum ErrorDomain: Error, Equatable{ case UTINotFound case UnableToLoadFile @@ -33,17 +39,6 @@ public final class ItemProviderFileRepresentation: NSObject, ProgressResultable public typealias Success = URL public typealias Failure = Error - /// Track task progress with internal Combine pipe - private let resultProcessed = PassthroughSubject() - - /// Internal observation of the Combine progress Pipe - private var resultProcessedObserver: AnyCancellable? - - /// Internal Task that wraps the combine result observation - private var computeResultTask: Task? - - private let fileManager = FileManager.default - public init(from itemProvider: NSItemProvider) throws { guard let typeIdentifier = itemProvider.registeredTypeIdentifiers.first else { throw ErrorDomain.UTINotFound @@ -55,9 +50,9 @@ public final class ItemProviderFileRepresentation: NSObject, ProgressResultable super.init() // Set progress and hook completion closure to a combine pipe - progress = itemProvider.loadFileRepresentation(forTypeIdentifier: typeIdentifier) { fileProviderURL, error in + progress = itemProvider.loadFileRepresentation(forTypeIdentifier: typeIdentifier) { [self] fileProviderURL, error in guard let fileProviderURL, error == nil else { - self.resultProcessed.send(completion: .failure(error ?? ErrorDomain.UnableToLoadFile)) + flowToAsync.send(completion: .failure(error ?? ErrorDomain.UnableToLoadFile)) return } @@ -66,49 +61,27 @@ public final class ItemProviderFileRepresentation: NSObject, ProgressResultable @InjectService var pathProvider: AppGroupPathProvidable let temporaryURL = pathProvider.tmpDirectoryURL .appendingPathComponent(UUID().uuidString, isDirectory: true) - try self.fileManager.createDirectory(at: temporaryURL, withIntermediateDirectories: true) + try fileManager.createDirectory(at: temporaryURL, withIntermediateDirectories: true) let fileName = fileProviderURL.appendingPathExtension(for: UTI).lastPathComponent let temporaryFileURL = temporaryURL.appendingPathComponent(fileName) - try self.fileManager.copyItem(atPath: fileProviderURL.path, toPath: temporaryFileURL.path) - self.resultProcessed.send(temporaryFileURL) - self.resultProcessed.send(completion: .finished) + try fileManager.copyItem(atPath: fileProviderURL.path, toPath: temporaryFileURL.path) + + flowToAsync.send(temporaryFileURL) + flowToAsync.send(completion: .finished) } catch { - self.resultProcessed.send(completion: .failure(error)) + flowToAsync.send(completion: .failure(error)) } } - - /// Wrap the Combine pipe to a native Swift Async Task for convenience - computeResultTask = Task { - let resultURL: URL = try await withCheckedThrowingContinuation { continuation in - self.resultProcessedObserver = resultProcessed.sink { result in - switch result { - case .finished: - break - case .failure(let error): - continuation.resume(throwing: error) - } - self.resultProcessedObserver?.cancel() - } receiveValue: { value in - continuation.resume(with: .success(value)) - } - } - - return resultURL - } } - // MARK: Public + // MARK: ProgressResultable public var progress: Progress public var result: Result { get async { - guard let computeResultTask else { - fatalError("This never should be nil") - } - - return await computeResultTask.result + await self.flowToAsync.result } } } diff --git a/Sources/InfomaniakCore/ItemProviderRepresentation/ItemProviderTextRepresentation.swift b/Sources/InfomaniakCore/ItemProviderRepresentation/ItemProviderTextRepresentation.swift index 12c8902..ba72902 100644 --- a/Sources/InfomaniakCore/ItemProviderRepresentation/ItemProviderTextRepresentation.swift +++ b/Sources/InfomaniakCore/ItemProviderRepresentation/ItemProviderTextRepresentation.swift @@ -18,13 +18,19 @@ #if canImport(MobileCoreServices) -import Combine import Foundation import InfomaniakDI /// Something that can provide a `Progress` and an async `Result` in order to make a raw text file from a `NSItemProvider` @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) public final class ItemProviderTextRepresentation: NSObject, ProgressResultable { + /// Something to transform events to a nice `async Result` + private let flowToAsync = FlowToAsyncResult() + + /// Shorthand for default FileManager + private let fileManager = FileManager.default + + /// Domain specific errors public enum ErrorDomain: Error, Equatable { case UTINotFound case UTINotSupported @@ -36,17 +42,6 @@ public final class ItemProviderTextRepresentation: NSObject, ProgressResultable private static let progressStep: Int64 = 1 - /// Track task progress with internal Combine pipe - private let resultProcessed = PassthroughSubject() - - /// Internal observation of the Combine progress Pipe - private var resultProcessedObserver: AnyCancellable? - - /// Internal Task that wraps the combine result observation - private var computeResultTask: Task? - - private let fileManager = FileManager.default - public init(from itemProvider: NSItemProvider) throws { guard let typeIdentifier = itemProvider.registeredTypeIdentifiers.first else { throw ErrorDomain.UTINotFound @@ -60,14 +55,14 @@ public final class ItemProviderTextRepresentation: NSObject, ProgressResultable let childProgress = Progress() progress.addChild(childProgress, withPendingUnitCount: Self.progressStep) - itemProvider.loadItem(forTypeIdentifier: typeIdentifier) { coding, error in + itemProvider.loadItem(forTypeIdentifier: typeIdentifier) { [self] coding, error in defer { childProgress.completedUnitCount += Self.progressStep } guard error == nil, let coding else { - self.resultProcessed.send(completion: .failure(error ?? ErrorDomain.unknown)) + flowToAsync.send(completion: .failure(error ?? ErrorDomain.unknown)) return } @@ -76,45 +71,26 @@ public final class ItemProviderTextRepresentation: NSObject, ProgressResultable @InjectService var pathProvider: AppGroupPathProvidable let temporaryURL = pathProvider.tmpDirectoryURL .appendingPathComponent(UUID().uuidString, isDirectory: true) - try self.fileManager.createDirectory(at: temporaryURL, withIntermediateDirectories: true) + try fileManager.createDirectory(at: temporaryURL, withIntermediateDirectories: true) // Is String - guard try !self.stringHandling(coding, temporaryURL: temporaryURL) else { + guard try !stringHandling(coding, temporaryURL: temporaryURL) else { return } // Is Data - guard try !self.dataHandling(coding, typeIdentifier: typeIdentifier, temporaryURL: temporaryURL) else { + guard try !dataHandling(coding, typeIdentifier: typeIdentifier, temporaryURL: temporaryURL) else { return } // Not supported - self.resultProcessed.send(completion: .failure(ErrorDomain.UTINotSupported)) + flowToAsync.send(completion: .failure(ErrorDomain.UTINotSupported)) } catch { - self.resultProcessed.send(completion: .failure(error)) + flowToAsync.send(completion: .failure(error)) return } } - - /// Wrap the Combine pipe to a native Swift Async Task for convenience - computeResultTask = Task { - let resultURL: URL = try await withCheckedThrowingContinuation { continuation in - self.resultProcessedObserver = resultProcessed.sink { result in - switch result { - case .finished: - break - case .failure(let error): - continuation.resume(throwing: error) - } - self.resultProcessedObserver?.cancel() - } receiveValue: { value in - continuation.resume(with: .success(value)) - } - } - - return resultURL - } } private func stringHandling(_ coding: NSSecureCoding, temporaryURL: URL) throws -> Bool { @@ -124,8 +100,8 @@ public final class ItemProviderTextRepresentation: NSObject, ProgressResultable let targetURL = temporaryURL.appendingPathComponent("\(UUID().uuidString).txt") try text.write(to: targetURL, atomically: true, encoding: .utf8) - resultProcessed.send(targetURL) - resultProcessed.send(completion: .finished) + flowToAsync.send(targetURL) + flowToAsync.send(completion: .finished) return true } @@ -136,7 +112,7 @@ public final class ItemProviderTextRepresentation: NSObject, ProgressResultable } guard let uti = UTI(typeIdentifier) else { - resultProcessed.send(completion: .failure(ErrorDomain.UTINotFound)) + flowToAsync.send(completion: .failure(ErrorDomain.UTINotFound)) return false } @@ -145,23 +121,19 @@ public final class ItemProviderTextRepresentation: NSObject, ProgressResultable .appendingPathExtension(for: uti) try data.write(to: targetURL) - resultProcessed.send(targetURL) - resultProcessed.send(completion: .finished) + flowToAsync.send(targetURL) + flowToAsync.send(completion: .finished) return true } - // MARK: Public + // MARK: ProgressResultable public var progress: Progress public var result: Result { get async { - guard let computeResultTask else { - fatalError("This never should be nil") - } - - return await computeResultTask.result + return await flowToAsync.result } } } diff --git a/Sources/InfomaniakCore/ItemProviderRepresentation/ItemProviderWeblocRepresentation.swift b/Sources/InfomaniakCore/ItemProviderRepresentation/ItemProviderWeblocRepresentation.swift index bd0baff..2757648 100644 --- a/Sources/InfomaniakCore/ItemProviderRepresentation/ItemProviderWeblocRepresentation.swift +++ b/Sources/InfomaniakCore/ItemProviderRepresentation/ItemProviderWeblocRepresentation.swift @@ -18,13 +18,19 @@ #if canImport(MobileCoreServices) -import Combine import Foundation import InfomaniakDI /// Something that can provide a `Progress` and an async `Result` in order to make a webloc plist from a `NSItemProvider` @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) public final class ItemProviderWeblocRepresentation: NSObject, ProgressResultable { + /// Something to transform events to a nice `async Result` + private let flowToAsync = FlowToAsyncResult() + + /// Shorthand for default FileManager + private let fileManager = FileManager.default + + /// Domain specific errors public enum ErrorDomain: Error, Equatable { case unableToLoadURLForObject } @@ -32,27 +38,16 @@ public final class ItemProviderWeblocRepresentation: NSObject, ProgressResultabl public typealias Success = URL public typealias Failure = Error - /// Track task progress with internal Combine pipe - private let resultProcessed = PassthroughSubject() - - /// Internal observation of the Combine progress Pipe - private var resultProcessedObserver: AnyCancellable? - - /// Internal Task that wraps the combine result observation - private var computeResultTask: Task? - - private let fileManager = FileManager.default - public init(from itemProvider: NSItemProvider) throws { // Keep compiler happy progress = Progress(totalUnitCount: 1) super.init() - progress = itemProvider.loadObject(ofClass: URL.self) { path, error in + progress = itemProvider.loadObject(ofClass: URL.self) { [self] path, error in guard error == nil, let path: URL = path else { let error: Error = error ?? ErrorDomain.unableToLoadURLForObject - self.resultProcessed.send(completion: .failure(error)) + flowToAsync.send(completion: .failure(error)) return } @@ -63,7 +58,7 @@ public final class ItemProviderWeblocRepresentation: NSObject, ProgressResultabl @InjectService var pathProvider: AppGroupPathProvidable let tmpDirectoryURL = pathProvider.tmpDirectoryURL .appendingPathComponent(UUID().uuidString, isDirectory: true) - try self.fileManager.createDirectory(at: tmpDirectoryURL, withIntermediateDirectories: true) + try fileManager.createDirectory(at: tmpDirectoryURL, withIntermediateDirectories: true) let fileName = path.lastPathComponent let targetURL = tmpDirectoryURL.appendingPathComponent("\(fileName).webloc") @@ -71,44 +66,21 @@ public final class ItemProviderWeblocRepresentation: NSObject, ProgressResultabl let data = try encoder.encode(content) try data.write(to: targetURL) - self.resultProcessed.send(targetURL) - self.resultProcessed.send(completion: .finished) + flowToAsync.send(targetURL) + flowToAsync.send(completion: .finished) } catch { - self.resultProcessed.send(completion: .failure(error)) + flowToAsync.send(completion: .failure(error)) } } - - /// Wrap the Combine pipe to a native Swift Async Task for convenience - computeResultTask = Task { - let resultURL: URL = try await withCheckedThrowingContinuation { continuation in - self.resultProcessedObserver = resultProcessed.sink { result in - switch result { - case .finished: - break - case .failure(let error): - continuation.resume(throwing: error) - } - self.resultProcessedObserver?.cancel() - } receiveValue: { value in - continuation.resume(with: .success(value)) - } - } - - return resultURL - } } - // MARK: Public + // MARK: ProgressResultable public var progress: Progress public var result: Result { get async { - guard let computeResultTask else { - fatalError("This never should be nil") - } - - return await computeResultTask.result + return await flowToAsync.result } } } diff --git a/Sources/InfomaniakCore/ItemProviderRepresentation/ItemProviderZipRepresentation.swift b/Sources/InfomaniakCore/ItemProviderRepresentation/ItemProviderZipRepresentation.swift index 34ad389..1e693f7 100644 --- a/Sources/InfomaniakCore/ItemProviderRepresentation/ItemProviderZipRepresentation.swift +++ b/Sources/InfomaniakCore/ItemProviderRepresentation/ItemProviderZipRepresentation.swift @@ -25,6 +25,13 @@ import InfomaniakDI /// Something that can provide a `Progress` and an async `Result` in order to make a zip from a `NSItemProvider` @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) public final class ItemProviderZipRepresentation: NSObject, ProgressResultable { + /// Something to transform events to a nice `async Result` + private let flowToAsync = FlowToAsyncResult() + + /// Shorthand for default FileManager + private let fileManager = FileManager.default + + /// Domain specific errors public enum ErrorDomain: Error, Equatable { case unableToLoadURLForObject case notADirectory @@ -44,8 +51,6 @@ public final class ItemProviderZipRepresentation: NSObject, ProgressResultable { /// Internal Task that wraps the combine result observation private var computeResultTask: Task? - private let fileManager = FileManager.default - public init(from itemProvider: NSItemProvider) throws { // It must be a directory for the OS to zip it for us, a file returns a file guard itemProvider.underlyingType == .isDirectory else { @@ -77,57 +82,34 @@ public final class ItemProviderZipRepresentation: NSObject, ProgressResultable { // compress content of folder and move it somewhere we can safely store it for upload var error: NSError? - coordinator.coordinate(readingItemAt: path, options: [.forUploading], error: &error) { zipURL in + coordinator.coordinate(readingItemAt: path, options: [.forUploading], error: &error) { [self] zipURL in do { @InjectService var pathProvider: AppGroupPathProvidable let tmpDirectoryURL = pathProvider.tmpDirectoryURL .appendingPathComponent(UUID().uuidString, isDirectory: true) - try self.fileManager.createDirectory(at: tmpDirectoryURL, withIntermediateDirectories: true) + try fileManager.createDirectory(at: tmpDirectoryURL, withIntermediateDirectories: true) let fileName = path.lastPathComponent let targetURL = tmpDirectoryURL.appendingPathComponent("\(fileName).zip") - try self.fileManager.moveItem(at: zipURL, to: targetURL) - self.resultProcessed.send(targetURL) - self.resultProcessed.send(completion: .finished) + try fileManager.moveItem(at: zipURL, to: targetURL) + flowToAsync.send(targetURL) + flowToAsync.send(completion: .finished) } catch { - self.resultProcessed.send(completion: .failure(error)) + flowToAsync.send(completion: .failure(error)) } childProgress.completedUnitCount += Self.progressStep } } - - /// Wrap the Combine pipe to a native Swift Async Task for convenience - computeResultTask = Task { - let resultURL: URL = try await withCheckedThrowingContinuation { continuation in - self.resultProcessedObserver = resultProcessed.sink { result in - switch result { - case .finished: - break - case .failure(let error): - continuation.resume(throwing: error) - } - self.resultProcessedObserver?.cancel() - } receiveValue: { value in - continuation.resume(with: .success(value)) - } - } - - return resultURL - } } - // MARK: Public + // MARK: ProgressResultable public var progress: Progress public var result: Result { get async { - guard let computeResultTask else { - fatalError("This never should be nil") - } - - return await computeResultTask.result + return await flowToAsync.result } } }