Skip to content

Commit

Permalink
refactor(FlowToAsyncResult): Abstracted the Flow to 'async Result<> h…
Browse files Browse the repository at this point in the history
…andling'
  • Loading branch information
adrien-coye committed Jul 18, 2023
1 parent 79897ca commit b64ea3b
Show file tree
Hide file tree
Showing 5 changed files with 150 additions and 169 deletions.
82 changes: 82 additions & 0 deletions Sources/InfomaniakCore/Asynchronous/FlowToAsyncResult.swift
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/

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<Success> {
// 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<Success, Error> = 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<Success, Error>()

/// Provides a nice `async Result` public API
public var result: Result<Success, Error> {
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<Error>) {
flow.send(completion: completion)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Success>()

/// Shorthand for default FileManager
private let fileManager = FileManager.default

/// Domain specific errors
public enum ErrorDomain: Error, Equatable{
case UTINotFound
case UnableToLoadFile
Expand All @@ -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<Success, Failure>()

/// Internal observation of the Combine progress Pipe
private var resultProcessedObserver: AnyCancellable?

/// Internal Task that wraps the combine result observation
private var computeResultTask: Task<Success, Failure>?

private let fileManager = FileManager.default

public init(from itemProvider: NSItemProvider) throws {
guard let typeIdentifier = itemProvider.registeredTypeIdentifiers.first else {
throw ErrorDomain.UTINotFound
Expand All @@ -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
}

Expand All @@ -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<URL, Error> {
get async {
guard let computeResultTask else {
fatalError("This never should be nil")
}

return await computeResultTask.result
await self.flowToAsync.result
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Success>()

/// Shorthand for default FileManager
private let fileManager = FileManager.default

/// Domain specific errors
public enum ErrorDomain: Error, Equatable {
case UTINotFound
case UTINotSupported
Expand All @@ -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<Success, Failure>()

/// Internal observation of the Combine progress Pipe
private var resultProcessedObserver: AnyCancellable?

/// Internal Task that wraps the combine result observation
private var computeResultTask: Task<Success, Failure>?

private let fileManager = FileManager.default

public init(from itemProvider: NSItemProvider) throws {
guard let typeIdentifier = itemProvider.registeredTypeIdentifiers.first else {
throw ErrorDomain.UTINotFound
Expand All @@ -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
}

Expand All @@ -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 {
Expand All @@ -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
}
Expand All @@ -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
}

Expand All @@ -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<URL, Error> {
get async {
guard let computeResultTask else {
fatalError("This never should be nil")
}

return await computeResultTask.result
return await flowToAsync.result
}
}
}
Expand Down
Loading

0 comments on commit b64ea3b

Please sign in to comment.