summaryrefslogtreecommitdiffhomepage
path: root/ios/Operations
diff options
context:
space:
mode:
authorAndrej Mihajlov <and@mullvad.net>2022-09-25 16:34:23 +0200
committerAndrej Mihajlov <and@mullvad.net>2022-09-26 16:35:38 +0200
commitf389956c2cd884df142adbf00ff2ac7e2f69c2b2 (patch)
tree5f7d4869324760eb4ba0107c0356edc89efd1457 /ios/Operations
parent2e83b1ca27ff243a615ff10c94c20840b38dfd45 (diff)
downloadmullvadvpn-f389956c2cd884df142adbf00ff2ac7e2f69c2b2.tar.xz
mullvadvpn-f389956c2cd884df142adbf00ff2ac7e2f69c2b2.zip
Move AsyncOperation into Operations static library and add separate tests
Diffstat (limited to 'ios/Operations')
-rw-r--r--ios/Operations/AlertPresenter.swift72
-rw-r--r--ios/Operations/AsyncBlockOperation.swift84
-rw-r--r--ios/Operations/AsyncOperation.swift433
-rw-r--r--ios/Operations/AsyncOperationQueue.swift98
-rw-r--r--ios/Operations/BackgroundObserver.swift54
-rw-r--r--ios/Operations/BlockCondition.swift30
-rw-r--r--ios/Operations/GroupOperation.swift35
-rw-r--r--ios/Operations/InputInjectionBuilder.swift96
-rw-r--r--ios/Operations/InputOperation.swift47
-rw-r--r--ios/Operations/MutuallyExclusive.swift25
-rw-r--r--ios/Operations/NoCancelledDependenciesCondition.swift29
-rw-r--r--ios/Operations/NoFailedDependenciesCondition.swift40
-rw-r--r--ios/Operations/OperationCompletion.swift145
-rw-r--r--ios/Operations/OperationCondition.swift16
-rw-r--r--ios/Operations/OperationObserver.swift63
-rw-r--r--ios/Operations/OutputOperation.swift15
-rw-r--r--ios/Operations/PresentAlertOperation.swift75
-rw-r--r--ios/Operations/ProductsRequestOperation.swift92
-rw-r--r--ios/Operations/ResultBlockOperation.swift120
-rw-r--r--ios/Operations/ResultOperation+Output.swift15
-rw-r--r--ios/Operations/ResultOperation.swift131
-rw-r--r--ios/Operations/TransformOperation.swift138
22 files changed, 1853 insertions, 0 deletions
diff --git a/ios/Operations/AlertPresenter.swift b/ios/Operations/AlertPresenter.swift
new file mode 100644
index 0000000000..6bf4470e23
--- /dev/null
+++ b/ios/Operations/AlertPresenter.swift
@@ -0,0 +1,72 @@
+//
+// AlertPresenter.swift
+// MullvadVPN
+//
+// Created by pronebird on 04/06/2020.
+// Copyright © 2020 Mullvad VPN AB. All rights reserved.
+//
+
+#if canImport(UIKit)
+
+import UIKit
+
+public final class AlertPresenter {
+ static let alertControllerDidDismissNotification = Notification
+ .Name("UIAlertControllerDidDismiss")
+
+ private let operationQueue: OperationQueue = {
+ let operationQueue = AsyncOperationQueue()
+ operationQueue.name = "AlertPresenterQueue"
+ operationQueue.maxConcurrentOperationCount = 1
+ return operationQueue
+ }()
+
+ private static let initClass: Void = {
+ /// Swizzle `viewDidDisappear` on `UIAlertController` in order to be able to
+ /// detect when the controller disappears.
+ /// The event is broadcasted via `AlertPresenter.alertControllerDidDismissNotification` notification.
+ swizzleMethod(
+ aClass: UIAlertController.self,
+ originalSelector: #selector(UIAlertController.viewDidDisappear(_:)),
+ newSelector: #selector(UIAlertController.alertPresenter_viewDidDisappear(_:))
+ )
+ }()
+
+ public init() {
+ _ = Self.initClass
+ }
+
+ public func enqueue(
+ _ alertController: UIAlertController,
+ presentingController: UIViewController,
+ presentCompletion: (() -> Void)? = nil
+ ) {
+ let operation = PresentAlertOperation(
+ alertController: alertController,
+ presentingController: presentingController,
+ presentCompletion: presentCompletion
+ )
+
+ operationQueue.addOperation(operation)
+ }
+
+ public func cancelAll() {
+ operationQueue.cancelAllOperations()
+ }
+}
+
+private extension UIAlertController {
+ @objc dynamic func alertPresenter_viewDidDisappear(_ animated: Bool) {
+ // Call super implementation
+ alertPresenter_viewDidDisappear(animated)
+
+ if presentingViewController == nil {
+ NotificationCenter.default.post(
+ name: AlertPresenter.alertControllerDidDismissNotification,
+ object: self
+ )
+ }
+ }
+}
+
+#endif
diff --git a/ios/Operations/AsyncBlockOperation.swift b/ios/Operations/AsyncBlockOperation.swift
new file mode 100644
index 0000000000..72b95c98a3
--- /dev/null
+++ b/ios/Operations/AsyncBlockOperation.swift
@@ -0,0 +1,84 @@
+//
+// AsyncBlockOperation.swift
+// MullvadVPN
+//
+// Created by pronebird on 06/07/2020.
+// Copyright © 2020 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+
+/// Asynchronous block operation
+public class AsyncBlockOperation: AsyncOperation {
+ private var executionBlock: ((AsyncBlockOperation) -> Void)?
+ private var cancellationBlocks: [() -> Void] = []
+
+ override public init(dispatchQueue: DispatchQueue? = nil) {
+ super.init(dispatchQueue: dispatchQueue)
+ }
+
+ public init(
+ dispatchQueue: DispatchQueue? = nil,
+ block: @escaping (AsyncBlockOperation) -> Void
+ ) {
+ executionBlock = block
+ super.init(dispatchQueue: dispatchQueue)
+ }
+
+ public init(dispatchQueue: DispatchQueue? = nil, block: @escaping () -> Void) {
+ executionBlock = { operation in
+ block()
+ operation.finish()
+ }
+ super.init(dispatchQueue: dispatchQueue)
+ }
+
+ override public func main() {
+ let block = executionBlock
+ executionBlock = nil
+
+ if let block = block {
+ block(self)
+ } else {
+ finish()
+ }
+ }
+
+ override public func operationDidCancel() {
+ let blocks = cancellationBlocks
+ cancellationBlocks.removeAll()
+
+ for block in blocks {
+ block()
+ }
+ }
+
+ override public func operationDidFinish() {
+ cancellationBlocks.removeAll()
+ executionBlock = nil
+ }
+
+ public func setExecutionBlock(_ block: @escaping (AsyncBlockOperation) -> Void) {
+ dispatchQueue.async {
+ assert(!self.isExecuting && !self.isFinished)
+ self.executionBlock = block
+ }
+ }
+
+ public func setExecutionBlock(_ block: @escaping () -> Void) {
+ setExecutionBlock { operation in
+ block()
+ operation.finish()
+ }
+ }
+
+ public func addCancellationBlock(_ block: @escaping () -> Void) {
+ dispatchQueue.async {
+ if self.isCancelled {
+ block()
+ } else {
+ self.cancellationBlocks.append(block)
+ }
+ }
+ }
+}
diff --git a/ios/Operations/AsyncOperation.swift b/ios/Operations/AsyncOperation.swift
new file mode 100644
index 0000000000..a8aa4262a8
--- /dev/null
+++ b/ios/Operations/AsyncOperation.swift
@@ -0,0 +1,433 @@
+//
+// AsyncOperation.swift
+// MullvadVPN
+//
+// Created by pronebird on 01/06/2020.
+// Copyright © 2020 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+
+@objc private enum State: Int, Comparable, CustomStringConvertible {
+ case initialized
+ case pending
+ case evaluatingConditions
+ case ready
+ case executing
+ case finished
+
+ static func < (lhs: State, rhs: State) -> Bool {
+ return lhs.rawValue < rhs.rawValue
+ }
+
+ var description: String {
+ switch self {
+ case .initialized:
+ return "initialized"
+ case .pending:
+ return "pending"
+ case .evaluatingConditions:
+ return "evaluatingConditions"
+ case .ready:
+ return "ready"
+ case .executing:
+ return "executing"
+ case .finished:
+ return "finished"
+ }
+ }
+}
+
+/// A base implementation of an asynchronous operation
+open class AsyncOperation: Operation {
+ /// Mutex lock used for guarding critical sections of operation lifecycle.
+ private let operationLock = NSRecursiveLock()
+
+ /// Mutex lock used to guard `state` and `isCancelled` properties.
+ ///
+ /// This lock must not encompass KVO hooks such as `willChangeValue` and `didChangeValue` to
+ /// prevent deadlocks, since KVO observers may synchronously query the operation state on a
+ /// different thread.
+ ///
+ /// `operationLock` should be used along with `stateLock` to ensure internal state consistency
+ /// when multiple access to `state` or `isCancelled` is necessary, such as when testing
+ /// the value before modifying it.
+ private let stateLock = NSRecursiveLock()
+
+ /// Backing variable for `state`.
+ /// Access must be guarded with `stateLock`.
+ private var _state: State = .initialized
+
+ /// Backing variable for `_isCancelled`.
+ /// Access must be guarded with `stateLock`.
+ private var __isCancelled = false
+
+ /// Backing variable for `error`.
+ /// Access must be guarded with `stateLock`.
+ private var __error: Error?
+
+ /// Operation state.
+ @objc private var state: State {
+ get {
+ stateLock.lock()
+ defer { stateLock.unlock() }
+
+ return _state
+ }
+ set(newState) {
+ willChangeValue(for: \.state)
+ stateLock.lock()
+ assert(_state < newState)
+ _state = newState
+ stateLock.unlock()
+ didChangeValue(for: \.state)
+ }
+ }
+
+ private var _isCancelled: Bool {
+ get {
+ stateLock.lock()
+ defer { stateLock.unlock() }
+
+ return __isCancelled
+ }
+ set {
+ willChangeValue(for: \.isCancelled)
+ stateLock.lock()
+ __isCancelled = newValue
+ stateLock.unlock()
+ didChangeValue(for: \.isCancelled)
+ }
+ }
+
+ private var _error: Error? {
+ get {
+ stateLock.lock()
+ defer { stateLock.unlock() }
+ return __error
+ }
+ set {
+ stateLock.lock()
+ defer { stateLock.unlock() }
+ __error = newValue
+ }
+ }
+
+ public var error: Error? {
+ return _error
+ }
+
+ override public final var isReady: Bool {
+ stateLock.lock()
+ defer { stateLock.unlock() }
+
+ // super.isReady should turn true when all dependencies are satisfied.
+ guard super.isReady else {
+ return false
+ }
+
+ // Mark operation ready when cancelled, so that operation queue could flush it faster.
+ guard !__isCancelled else {
+ return true
+ }
+
+ switch _state {
+ case .initialized, .pending, .evaluatingConditions:
+ return false
+
+ case .ready, .executing, .finished:
+ return true
+ }
+ }
+
+ override public final var isExecuting: Bool {
+ return state == .executing
+ }
+
+ override public final var isFinished: Bool {
+ return state == .finished
+ }
+
+ override public final var isCancelled: Bool {
+ return _isCancelled
+ }
+
+ override public final var isAsynchronous: Bool {
+ return true
+ }
+
+ // MARK: - Observers
+
+ private var _observers: [OperationObserver] = []
+
+ public final var observers: [OperationObserver] {
+ operationLock.lock()
+ defer { operationLock.unlock() }
+
+ return _observers
+ }
+
+ public final func addObserver(_ observer: OperationObserver) {
+ operationLock.lock()
+ assert(state < .executing)
+ _observers.append(observer)
+ operationLock.unlock()
+ observer.didAttach(to: self)
+ }
+
+ // MARK: - Conditions
+
+ private var _conditions: [OperationCondition] = []
+
+ public final var conditions: [OperationCondition] {
+ operationLock.lock()
+ defer { operationLock.unlock() }
+
+ return _conditions
+ }
+
+ public func addCondition(_ condition: OperationCondition) {
+ operationLock.lock()
+ assert(state < .evaluatingConditions)
+ _conditions.append(condition)
+ operationLock.unlock()
+ }
+
+ private func evaluateConditions() {
+ guard !_conditions.isEmpty else {
+ state = .ready
+ return
+ }
+
+ state = .evaluatingConditions
+
+ var results = [Bool](repeating: false, count: _conditions.count)
+ let group = DispatchGroup()
+
+ for (index, condition) in _conditions.enumerated() {
+ group.enter()
+ condition.evaluate(for: self) { [weak self] isSatisfied in
+ self?.dispatchQueue.async {
+ results[index] = isSatisfied
+ group.leave()
+ }
+ }
+ }
+
+ group.notify(queue: dispatchQueue) { [weak self] in
+ self?.didEvaluateConditions(results)
+ }
+ }
+
+ private func didEvaluateConditions(_ results: [Bool]) {
+ operationLock.lock()
+ defer { operationLock.unlock() }
+
+ guard state < .ready else { return }
+
+ let conditionsSatisfied = results.allSatisfy { $0 }
+ if !conditionsSatisfied {
+ cancel()
+ }
+
+ state = .ready
+ }
+
+ // MARK: -
+
+ public let dispatchQueue: DispatchQueue
+
+ public init(dispatchQueue: DispatchQueue? = nil) {
+ self.dispatchQueue = dispatchQueue ?? DispatchQueue(label: "AsyncOperation.dispatchQueue")
+ super.init()
+
+ addObserver(
+ self,
+ forKeyPath: #keyPath(isReady),
+ options: [],
+ context: &Self.observerContext
+ )
+ }
+
+ deinit {
+ removeObserver(self, forKeyPath: #keyPath(isReady), context: &Self.observerContext)
+ }
+
+ // MARK: - KVO
+
+ private static var observerContext = 0
+
+ override public func observeValue(
+ forKeyPath keyPath: String?,
+ of object: Any?,
+ change: [NSKeyValueChangeKey: Any]?,
+ context: UnsafeMutableRawPointer?
+ ) {
+ if context == &Self.observerContext {
+ checkReadiness()
+ return
+ }
+
+ super.observeValue(
+ forKeyPath: keyPath,
+ of: object,
+ change: change,
+ context: context
+ )
+ }
+
+ @objc class func keyPathsForValuesAffectingIsReady() -> Set<String> {
+ return [#keyPath(state)]
+ }
+
+ @objc class func keyPathsForValuesAffectingIsExecuting() -> Set<String> {
+ return [#keyPath(state)]
+ }
+
+ @objc class func keyPathsForValuesAffectingIsFinished() -> Set<String> {
+ return [#keyPath(state)]
+ }
+
+ // MARK: - Lifecycle
+
+ override public final func start() {
+ let currentQueue = OperationQueue.current
+ let underlyingQueue = currentQueue?.underlyingQueue
+
+ if underlyingQueue == dispatchQueue {
+ _start()
+ } else {
+ dispatchQueue.async {
+ self._start()
+ }
+ }
+ }
+
+ private func _start() {
+ operationLock.lock()
+ if _isCancelled {
+ operationLock.unlock()
+ finish()
+ } else {
+ state = .executing
+
+ for observer in _observers {
+ observer.operationDidStart(self)
+ }
+ operationLock.unlock()
+
+ main()
+ }
+ }
+
+ override open func main() {
+ // Override in subclasses
+ }
+
+ override public final func cancel() {
+ var notifyDidCancel = false
+
+ operationLock.lock()
+ if !_isCancelled {
+ _isCancelled = true
+ notifyDidCancel = true
+ }
+ operationLock.unlock()
+
+ super.cancel()
+
+ if notifyDidCancel {
+ dispatchQueue.async {
+ self.operationDidCancel()
+
+ for observer in self.observers {
+ observer.operationDidCancel(self)
+ }
+ }
+ }
+ }
+
+ public func finish() {
+ finish(error: nil)
+ }
+
+ public func finish(error: Error?) {
+ guard tryFinish(error: error) else { return }
+
+ dispatchQueue.async {
+ self.operationDidFinish()
+
+ let anError = self.error
+ for observer in self.observers {
+ observer.operationDidFinish(self, error: anError)
+ }
+ }
+ }
+
+ // MARK: - Private
+
+ internal func didEnqueue() {
+ operationLock.lock()
+ defer { operationLock.unlock() }
+
+ guard state == .initialized else {
+ return
+ }
+
+ state = .pending
+ }
+
+ private func checkReadiness() {
+ operationLock.lock()
+ defer { operationLock.unlock() }
+
+ if state == .pending, !_isCancelled, super.isReady {
+ evaluateConditions()
+ }
+ }
+
+ private func tryFinish(error: Error?) -> Bool {
+ operationLock.lock()
+ defer { operationLock.unlock() }
+
+ guard state < .finished else { return false }
+
+ _error = error
+ state = .finished
+
+ return true
+ }
+
+ // MARK: - Subclass overrides
+
+ open func operationDidCancel() {
+ // Override in subclasses.
+ }
+
+ open func operationDidFinish() {
+ // Override in subclasses.
+ }
+}
+
+public extension Operation {
+ func addDependencies(_ dependencies: [Operation]) {
+ for dependency in dependencies {
+ addDependency(dependency)
+ }
+ }
+}
+
+public extension Operation {
+ var operationName: String {
+ return name ?? "\(self)"
+ }
+}
+
+public protocol OperationBlockObserverSupport {}
+extension AsyncOperation: OperationBlockObserverSupport {}
+
+public extension OperationBlockObserverSupport where Self: AsyncOperation {
+ func addBlockObserver(_ observer: OperationBlockObserver<Self>) {
+ addObserver(observer)
+ }
+}
diff --git a/ios/Operations/AsyncOperationQueue.swift b/ios/Operations/AsyncOperationQueue.swift
new file mode 100644
index 0000000000..1c62038671
--- /dev/null
+++ b/ios/Operations/AsyncOperationQueue.swift
@@ -0,0 +1,98 @@
+//
+// AsyncOperationQueue.swift
+// MullvadVPN
+//
+// Created by pronebird on 30/05/2022.
+// Copyright © 2022 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+
+public class AsyncOperationQueue: OperationQueue {
+ override public func addOperation(_ operation: Operation) {
+ if let operation = operation as? AsyncOperation {
+ let categories = operation.conditions
+ .filter { condition in
+ return condition.isMutuallyExclusive
+ }
+ .map { condition in
+ return condition.name
+ }
+
+ if !categories.isEmpty {
+ ExclusivityManager.shared.addOperation(operation, categories: Set(categories))
+ }
+
+ super.addOperation(operation)
+
+ operation.didEnqueue()
+ } else {
+ super.addOperation(operation)
+ }
+ }
+
+ override public func addOperations(_ operations: [Operation], waitUntilFinished wait: Bool) {
+ for operation in operations {
+ addOperation(operation)
+ }
+
+ if wait {
+ for operation in operations {
+ operation.waitUntilFinished()
+ }
+ }
+ }
+}
+
+private final class ExclusivityManager {
+ static let shared = ExclusivityManager()
+
+ private var operationsByCategory = [String: [Operation]]()
+ private let nslock = NSLock()
+
+ private init() {}
+
+ func addOperation(_ operation: AsyncOperation, categories: Set<String>) {
+ nslock.lock()
+ defer { nslock.unlock() }
+
+ for category in categories {
+ var operations = operationsByCategory[category] ?? []
+
+ if let lastOperation = operations.last {
+ operation.addDependency(lastOperation)
+ }
+
+ operations.append(operation)
+
+ operationsByCategory[category] = operations
+
+ let blockObserver = OperationBlockObserver(didFinish: { [weak self] op, error in
+ self?.removeOperation(op, categories: categories)
+ })
+
+ operation.addObserver(blockObserver)
+ }
+ }
+
+ private func removeOperation(_ operation: Operation, categories: Set<String>) {
+ nslock.lock()
+ defer { nslock.unlock() }
+
+ for category in categories {
+ guard var operations = operationsByCategory[category] else {
+ continue
+ }
+
+ if let index = operations.firstIndex(of: operation) {
+ operations.remove(at: index)
+ }
+
+ if operations.isEmpty {
+ operationsByCategory.removeValue(forKey: category)
+ } else {
+ operationsByCategory[category] = operations
+ }
+ }
+ }
+}
diff --git a/ios/Operations/BackgroundObserver.swift b/ios/Operations/BackgroundObserver.swift
new file mode 100644
index 0000000000..1de842f766
--- /dev/null
+++ b/ios/Operations/BackgroundObserver.swift
@@ -0,0 +1,54 @@
+//
+// BackgroundObserver.swift
+// MullvadVPN
+//
+// Created by pronebird on 31/05/2022.
+// Copyright © 2022 Mullvad VPN AB. All rights reserved.
+//
+
+#if canImport(UIKit)
+
+import UIKit
+
+public final class BackgroundObserver: OperationObserver {
+ public let name: String
+ public let application: UIApplication
+ public let cancelUponExpiration: Bool
+
+ private var taskIdentifier: UIBackgroundTaskIdentifier?
+
+ public init(
+ application: UIApplication = .shared,
+ name: String,
+ cancelUponExpiration: Bool
+ ) {
+ self.application = application
+ self.name = name
+ self.cancelUponExpiration = cancelUponExpiration
+ }
+
+ public func didAttach(to operation: Operation) {
+ let expirationHandler = cancelUponExpiration ? { operation.cancel() } : nil
+
+ taskIdentifier = application.beginBackgroundTask(
+ withName: name,
+ expirationHandler: expirationHandler
+ )
+ }
+
+ public func operationDidStart(_ operation: Operation) {
+ // no-op
+ }
+
+ public func operationDidCancel(_ operation: Operation) {
+ // no-op
+ }
+
+ public func operationDidFinish(_ operation: Operation, error: Error?) {
+ if let taskIdentifier = taskIdentifier {
+ application.endBackgroundTask(taskIdentifier)
+ }
+ }
+}
+
+#endif
diff --git a/ios/Operations/BlockCondition.swift b/ios/Operations/BlockCondition.swift
new file mode 100644
index 0000000000..e6aafa1e2d
--- /dev/null
+++ b/ios/Operations/BlockCondition.swift
@@ -0,0 +1,30 @@
+//
+// BlockCondition.swift
+// Operations
+//
+// Created by pronebird on 25/09/2022.
+// Copyright © 2022 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+
+public final class BlockCondition: OperationCondition {
+ public typealias HandlerBlock = (Operation, @escaping (Bool) -> Void) -> Void
+
+ public var name: String {
+ return "BlockCondition"
+ }
+
+ public var isMutuallyExclusive: Bool {
+ return false
+ }
+
+ public let block: HandlerBlock
+ public init(block: @escaping HandlerBlock) {
+ self.block = block
+ }
+
+ public func evaluate(for operation: Operation, completion: @escaping (Bool) -> Void) {
+ block(operation, completion)
+ }
+}
diff --git a/ios/Operations/GroupOperation.swift b/ios/Operations/GroupOperation.swift
new file mode 100644
index 0000000000..0d83d732fe
--- /dev/null
+++ b/ios/Operations/GroupOperation.swift
@@ -0,0 +1,35 @@
+//
+// GroupOperation.swift
+// MullvadVPN
+//
+// Created by pronebird on 31/05/2022.
+// Copyright © 2022 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+
+public final class GroupOperation: AsyncOperation {
+ private let operationQueue = AsyncOperationQueue()
+ private let children: [Operation]
+
+ public init(operations: [Operation]) {
+ children = operations
+
+ super.init(dispatchQueue: nil)
+ }
+
+ override public func main() {
+ let finishingOperation = BlockOperation()
+ finishingOperation.completionBlock = { [weak self] in
+ self?.finish()
+ }
+ finishingOperation.addDependencies(children)
+
+ operationQueue.addOperations(children, waitUntilFinished: false)
+ operationQueue.addOperation(finishingOperation)
+ }
+
+ override public func operationDidCancel() {
+ operationQueue.cancelAllOperations()
+ }
+}
diff --git a/ios/Operations/InputInjectionBuilder.swift b/ios/Operations/InputInjectionBuilder.swift
new file mode 100644
index 0000000000..ad2f329024
--- /dev/null
+++ b/ios/Operations/InputInjectionBuilder.swift
@@ -0,0 +1,96 @@
+//
+// InputInjectionBuilder.swift
+// MullvadVPN
+//
+// Created by pronebird on 09/06/2022.
+// Copyright © 2022 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+
+public protocol OperationInputContext {
+ associatedtype Input
+
+ func reduce() -> Input?
+}
+
+public final class InputInjectionBuilder<OperationType, Context>
+ where OperationType: InputOperation
+{
+ public typealias InputBlock = (inout Context) -> Void
+
+ private let operation: OperationType
+ private var context: Context
+ private var inputBlocks: [InputBlock] = []
+
+ public init(operation: OperationType, context: Context) {
+ self.operation = operation
+ self.context = context
+ }
+
+ public func inject<T>(
+ from dependency: T,
+ assignOutputTo keyPath: WritableKeyPath<Context, T.Output?>
+ ) -> Self
+ where T: OutputOperation
+ {
+ return inject(from: dependency) { context, output in
+ context[keyPath: keyPath] = output
+ }
+ }
+
+ public func inject<T>(
+ from dependency: T,
+ via block: @escaping (inout Context, T.Output) -> Void
+ ) -> Self
+ where T: OutputOperation
+ {
+ inputBlocks.append { context in
+ if let output = dependency.output {
+ block(&context, output)
+ }
+ }
+
+ operation.addDependency(dependency)
+
+ return self
+ }
+
+ public func injectCompletion<T, Success, Failure>(
+ from dependency: T,
+ via block: @escaping (inout Context, T.Completion) -> Void
+ ) -> Self
+ where T: ResultOperation<Success, Failure>
+ {
+ inputBlocks.append { context in
+ if let completion = dependency.completion {
+ block(&context, completion)
+ }
+ }
+
+ operation.addDependency(dependency)
+
+ return self
+ }
+
+ public func reduce(_ reduceBlock: @escaping (Context) -> OperationType.Input?) {
+ operation.setInputBlock {
+ for inputBlock in self.inputBlocks {
+ inputBlock(&self.context)
+ }
+
+ return reduceBlock(self.context)
+ }
+ }
+}
+
+public extension InputInjectionBuilder
+ where Context: OperationInputContext,
+ Context.Input == OperationType.Input
+{
+ func reduce() {
+ reduce { context in
+ return context.reduce()
+ }
+ }
+}
diff --git a/ios/Operations/InputOperation.swift b/ios/Operations/InputOperation.swift
new file mode 100644
index 0000000000..9d88861f4d
--- /dev/null
+++ b/ios/Operations/InputOperation.swift
@@ -0,0 +1,47 @@
+//
+// InputOperation.swift
+// MullvadVPN
+//
+// Created by pronebird on 09/06/2022.
+// Copyright © 2022 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+
+public protocol InputOperation: Operation {
+ associatedtype Input
+
+ var input: Input? { get }
+
+ func setInputBlock(_ block: @escaping () -> Input?)
+
+ func inject<T>(from dependency: T)
+ where T: OutputOperation, T.Output == Input
+
+ func inject<T>(from dependency: T, via block: @escaping (T.Output) -> Input)
+ where T: OutputOperation
+}
+
+public extension InputOperation {
+ func inject<T>(from dependency: T) where T: OutputOperation, T.Output == Input {
+ inject(from: dependency, via: { $0 })
+ }
+
+ func inject<T>(from dependency: T, via block: @escaping (T.Output) -> Input)
+ where T: OutputOperation
+ {
+ setInputBlock {
+ return dependency.output.map { value in
+ return block(value)
+ }
+ }
+ addDependency(dependency)
+ }
+
+ func injectMany<Context>(context: Context) -> InputInjectionBuilder<Self, Context> {
+ return InputInjectionBuilder(
+ operation: self,
+ context: context
+ )
+ }
+}
diff --git a/ios/Operations/MutuallyExclusive.swift b/ios/Operations/MutuallyExclusive.swift
new file mode 100644
index 0000000000..b9097d3420
--- /dev/null
+++ b/ios/Operations/MutuallyExclusive.swift
@@ -0,0 +1,25 @@
+//
+// MutuallyExclusive.swift
+// Operations
+//
+// Created by pronebird on 25/09/2022.
+// Copyright © 2022 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+
+public final class MutuallyExclusive: OperationCondition {
+ public let name: String
+
+ public var isMutuallyExclusive: Bool {
+ return true
+ }
+
+ public init(category: String) {
+ name = "MutuallyExclusive<\(category)>"
+ }
+
+ public func evaluate(for operation: Operation, completion: @escaping (Bool) -> Void) {
+ completion(true)
+ }
+}
diff --git a/ios/Operations/NoCancelledDependenciesCondition.swift b/ios/Operations/NoCancelledDependenciesCondition.swift
new file mode 100644
index 0000000000..da562153ce
--- /dev/null
+++ b/ios/Operations/NoCancelledDependenciesCondition.swift
@@ -0,0 +1,29 @@
+//
+// NoCancelledDependenciesCondition.swift
+// Operations
+//
+// Created by pronebird on 25/09/2022.
+// Copyright © 2022 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+
+public final class NoCancelledDependenciesCondition: OperationCondition {
+ public var name: String {
+ return "NoCancelledDependenciesCondition"
+ }
+
+ public var isMutuallyExclusive: Bool {
+ return false
+ }
+
+ public init() {}
+
+ public func evaluate(for operation: Operation, completion: @escaping (Bool) -> Void) {
+ let satisfy = operation.dependencies.allSatisfy { operation in
+ return !operation.isCancelled
+ }
+
+ completion(satisfy)
+ }
+}
diff --git a/ios/Operations/NoFailedDependenciesCondition.swift b/ios/Operations/NoFailedDependenciesCondition.swift
new file mode 100644
index 0000000000..2e96a12593
--- /dev/null
+++ b/ios/Operations/NoFailedDependenciesCondition.swift
@@ -0,0 +1,40 @@
+//
+// NoFailedDependenciesCondition.swift
+// Operations
+//
+// Created by pronebird on 25/09/2022.
+// Copyright © 2022 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+
+public final class NoFailedDependenciesCondition: OperationCondition {
+ public var name: String {
+ return "NoFailedDependenciesCondition"
+ }
+
+ public var isMutuallyExclusive: Bool {
+ return false
+ }
+
+ public let ignoreCancellations: Bool
+ public init(ignoreCancellations: Bool) {
+ self.ignoreCancellations = ignoreCancellations
+ }
+
+ public func evaluate(for operation: Operation, completion: @escaping (Bool) -> Void) {
+ let satisfy = operation.dependencies.allSatisfy { operation in
+ if let operation = operation as? AsyncOperation, operation.error != nil {
+ return false
+ }
+
+ if operation.isCancelled, !self.ignoreCancellations {
+ return false
+ }
+
+ return true
+ }
+
+ completion(satisfy)
+ }
+}
diff --git a/ios/Operations/OperationCompletion.swift b/ios/Operations/OperationCompletion.swift
new file mode 100644
index 0000000000..ffb8f54ce8
--- /dev/null
+++ b/ios/Operations/OperationCompletion.swift
@@ -0,0 +1,145 @@
+//
+// OperationCompletion.swift
+// MullvadVPN
+//
+// Created by pronebird on 24/01/2022.
+// Copyright © 2022 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+
+public enum OperationCompletion<Success, Failure: Error> {
+ case cancelled
+ case success(Success)
+ case failure(Failure)
+
+ public var isSuccess: Bool {
+ if case .success = self {
+ return true
+ } else {
+ return false
+ }
+ }
+
+ public var value: Success? {
+ if case let .success(value) = self {
+ return value
+ } else {
+ return nil
+ }
+ }
+
+ public var error: Failure? {
+ if case let .failure(error) = self {
+ return error
+ } else {
+ return nil
+ }
+ }
+
+ public var result: Result<Success, Failure>? {
+ switch self {
+ case let .success(value):
+ return .success(value)
+ case let .failure(error):
+ return .failure(error)
+ case .cancelled:
+ return nil
+ }
+ }
+
+ public init(result: Result<Success, Failure>) {
+ switch result {
+ case let .success(value):
+ self = .success(value)
+ case let .failure(error):
+ self = .failure(error)
+ }
+ }
+
+ public init(error: Failure?) where Success == Void {
+ if let error = error {
+ self = .failure(error)
+ } else {
+ self = .success(())
+ }
+ }
+
+ public func map<NewSuccess>(_ block: (Success) -> NewSuccess)
+ -> OperationCompletion<NewSuccess, Failure>
+ {
+ switch self {
+ case let .success(value):
+ return .success(block(value))
+ case let .failure(error):
+ return .failure(error)
+ case .cancelled:
+ return .cancelled
+ }
+ }
+
+ public func mapError<NewFailure: Error>(_ block: (Failure) -> NewFailure)
+ -> OperationCompletion<Success, NewFailure>
+ {
+ switch self {
+ case let .success(value):
+ return .success(value)
+ case let .failure(error):
+ return .failure(block(error))
+ case .cancelled:
+ return .cancelled
+ }
+ }
+
+ public func flatMap<NewSuccess>(_ block: (Success) -> OperationCompletion<NewSuccess, Failure>)
+ -> OperationCompletion<NewSuccess, Failure>
+ {
+ switch self {
+ case let .success(value):
+ return block(value)
+ case let .failure(error):
+ return .failure(error)
+ case .cancelled:
+ return .cancelled
+ }
+ }
+
+ public func flatMapError<NewFailure: Error>(
+ _ block: (Failure)
+ -> OperationCompletion<Success, NewFailure>
+ ) -> OperationCompletion<Success, NewFailure> {
+ switch self {
+ case let .success(value):
+ return .success(value)
+ case let .failure(error):
+ return block(error)
+ case .cancelled:
+ return .cancelled
+ }
+ }
+
+ public func tryMap<NewSuccess>(_ block: (Success) throws -> NewSuccess)
+ -> OperationCompletion<NewSuccess, Error>
+ {
+ switch self {
+ case let .success(value):
+ do {
+ return .success(try block(value))
+ } catch {
+ return .failure(error)
+ }
+ case let .failure(error):
+ return .failure(error)
+ case .cancelled:
+ return .cancelled
+ }
+ }
+
+ public func ignoreOutput() -> OperationCompletion<Void, Failure> {
+ return map { _ in () }
+ }
+
+ public func eraseFailureType() -> OperationCompletion<Success, Error> {
+ return mapError { $0 }
+ }
+}
diff --git a/ios/Operations/OperationCondition.swift b/ios/Operations/OperationCondition.swift
new file mode 100644
index 0000000000..7a30744c2d
--- /dev/null
+++ b/ios/Operations/OperationCondition.swift
@@ -0,0 +1,16 @@
+//
+// OperationCondition.swift
+// MullvadVPN
+//
+// Created by pronebird on 30/05/2022.
+// Copyright © 2022 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+
+public protocol OperationCondition {
+ var name: String { get }
+ var isMutuallyExclusive: Bool { get }
+
+ func evaluate(for operation: Operation, completion: @escaping (Bool) -> Void)
+}
diff --git a/ios/Operations/OperationObserver.swift b/ios/Operations/OperationObserver.swift
new file mode 100644
index 0000000000..db6264d2ba
--- /dev/null
+++ b/ios/Operations/OperationObserver.swift
@@ -0,0 +1,63 @@
+//
+// OperationObserver.swift
+// MullvadVPN
+//
+// Created by pronebird on 30/05/2022.
+// Copyright © 2022 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+
+public protocol OperationObserver {
+ func didAttach(to operation: Operation)
+ func operationDidStart(_ operation: Operation)
+ func operationDidCancel(_ operation: Operation)
+ func operationDidFinish(_ operation: Operation, error: Error?)
+}
+
+/// Block based operation observer.
+public class OperationBlockObserver<OperationType: Operation>: OperationObserver {
+ public typealias VoidBlock = (OperationType) -> Void
+ public typealias FinishBlock = (OperationType, Error?) -> Void
+
+ private let _didAttach: VoidBlock?
+ private let _didStart: VoidBlock?
+ private let _didCancel: VoidBlock?
+ private let _didFinish: FinishBlock?
+
+ public init(
+ didAttach: VoidBlock? = nil,
+ didStart: VoidBlock? = nil,
+ didCancel: VoidBlock? = nil,
+ didFinish: FinishBlock? = nil
+ ) {
+ _didAttach = didAttach
+ _didStart = didStart
+ _didCancel = didCancel
+ _didFinish = didFinish
+ }
+
+ public func didAttach(to operation: Operation) {
+ if let operation = operation as? OperationType {
+ _didAttach?(operation)
+ }
+ }
+
+ public func operationDidStart(_ operation: Operation) {
+ if let operation = operation as? OperationType {
+ _didStart?(operation)
+ }
+ }
+
+ public func operationDidCancel(_ operation: Operation) {
+ if let operation = operation as? OperationType {
+ _didCancel?(operation)
+ }
+ }
+
+ public func operationDidFinish(_ operation: Operation, error: Error?) {
+ if let operation = operation as? OperationType {
+ _didFinish?(operation, error)
+ }
+ }
+}
diff --git a/ios/Operations/OutputOperation.swift b/ios/Operations/OutputOperation.swift
new file mode 100644
index 0000000000..0e2e44525f
--- /dev/null
+++ b/ios/Operations/OutputOperation.swift
@@ -0,0 +1,15 @@
+//
+// OutputOperation.swift
+// MullvadVPN
+//
+// Created by pronebird on 31/05/2022.
+// Copyright © 2022 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+
+public protocol OutputOperation: Operation {
+ associatedtype Output
+
+ var output: Output? { get }
+}
diff --git a/ios/Operations/PresentAlertOperation.swift b/ios/Operations/PresentAlertOperation.swift
new file mode 100644
index 0000000000..f9fae07e0c
--- /dev/null
+++ b/ios/Operations/PresentAlertOperation.swift
@@ -0,0 +1,75 @@
+//
+// PresentAlertOperation.swift
+// PresentAlertOperation
+//
+// Created by pronebird on 06/09/2021.
+// Copyright © 2021 Mullvad VPN AB. All rights reserved.
+//
+
+#if canImport(UIKit)
+
+import UIKit
+
+public final class PresentAlertOperation: AsyncOperation {
+ private let alertController: UIAlertController
+ private let presentingController: UIViewController
+ private let presentCompletion: (() -> Void)?
+
+ public init(
+ alertController: UIAlertController,
+ presentingController: UIViewController,
+ presentCompletion: (() -> Void)? = nil
+ ) {
+ self.alertController = alertController
+ self.presentingController = presentingController
+ self.presentCompletion = presentCompletion
+
+ super.init(dispatchQueue: .main)
+ }
+
+ override public func operationDidCancel() {
+ // Guard against trying to dismiss the alert when operation hasn't started yet.
+ guard isExecuting else { return }
+
+ // Guard against dismissing controller during transition.
+ if !alertController.isBeingPresented, !alertController.isBeingDismissed {
+ dismissAndFinish()
+ }
+ }
+
+ override public func main() {
+ NotificationCenter.default.addObserver(
+ self,
+ selector: #selector(alertControllerDidDismiss(_:)),
+ name: AlertPresenter.alertControllerDidDismissNotification,
+ object: alertController
+ )
+
+ presentingController.present(alertController, animated: true) {
+ self.presentCompletion?()
+
+ // Alert operation was cancelled during transition?
+ if self.isCancelled {
+ self.dismissAndFinish()
+ }
+ }
+ }
+
+ private func dismissAndFinish() {
+ NotificationCenter.default.removeObserver(
+ self,
+ name: AlertPresenter.alertControllerDidDismissNotification,
+ object: alertController
+ )
+
+ alertController.dismiss(animated: false) {
+ self.finish()
+ }
+ }
+
+ @objc private func alertControllerDidDismiss(_ note: Notification) {
+ finish()
+ }
+}
+
+#endif
diff --git a/ios/Operations/ProductsRequestOperation.swift b/ios/Operations/ProductsRequestOperation.swift
new file mode 100644
index 0000000000..91635b3cd6
--- /dev/null
+++ b/ios/Operations/ProductsRequestOperation.swift
@@ -0,0 +1,92 @@
+//
+// ProductsRequestOperation.swift
+// ProductsRequestOperation
+//
+// Created by pronebird on 02/09/2021.
+// Copyright © 2021 Mullvad VPN AB. All rights reserved.
+//
+
+#if canImport(StoreKit)
+
+import StoreKit
+
+public final class ProductsRequestOperation: ResultOperation<SKProductsResponse, Error>,
+ SKProductsRequestDelegate
+{
+ private let productIdentifiers: Set<String>
+
+ private let maxRetryCount = 10
+ private let retryDelay: DispatchTimeInterval = .seconds(2)
+
+ private var retryCount = 0
+ private var retryTimer: DispatchSourceTimer?
+ private var request: SKProductsRequest?
+
+ public init(productIdentifiers: Set<String>, completionHandler: @escaping CompletionHandler) {
+ self.productIdentifiers = productIdentifiers
+
+ super.init(
+ dispatchQueue: .main,
+ completionQueue: .main,
+ completionHandler: completionHandler
+ )
+ }
+
+ override public func main() {
+ startRequest()
+ }
+
+ override public func operationDidCancel() {
+ request?.cancel()
+ retryTimer?.cancel()
+ }
+
+ // - MARK: SKProductsRequestDelegate
+
+ public func requestDidFinish(_ request: SKRequest) {
+ // no-op
+ }
+
+ public func request(_ request: SKRequest, didFailWithError error: Error) {
+ dispatchQueue.async {
+ if self.retryCount < self.maxRetryCount, !self.isCancelled {
+ self.retryCount += 1
+ self.retry(error: error)
+ } else {
+ self.finish(completion: .failure(error))
+ }
+ }
+ }
+
+ public func productsRequest(
+ _ request: SKProductsRequest,
+ didReceive response: SKProductsResponse
+ ) {
+ finish(completion: .success(response))
+ }
+
+ // MARK: - Private
+
+ private func startRequest() {
+ request = SKProductsRequest(productIdentifiers: productIdentifiers)
+ request?.delegate = self
+ request?.start()
+ }
+
+ private func retry(error: Error) {
+ retryTimer = DispatchSource.makeTimerSource(flags: [], queue: .main)
+
+ retryTimer?.setEventHandler { [weak self] in
+ self?.startRequest()
+ }
+
+ retryTimer?.setCancelHandler { [weak self] in
+ self?.finish(completion: .failure(error))
+ }
+
+ retryTimer?.schedule(wallDeadline: .now() + retryDelay)
+ retryTimer?.activate()
+ }
+}
+
+#endif
diff --git a/ios/Operations/ResultBlockOperation.swift b/ios/Operations/ResultBlockOperation.swift
new file mode 100644
index 0000000000..483731360a
--- /dev/null
+++ b/ios/Operations/ResultBlockOperation.swift
@@ -0,0 +1,120 @@
+//
+// ResultBlockOperation.swift
+// MullvadVPN
+//
+// Created by pronebird on 12/05/2022.
+// Copyright © 2022 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+
+public final class ResultBlockOperation<Success, Failure: Error>: ResultOperation<
+ Success,
+ Failure
+> {
+ public typealias ExecutionBlock = (ResultBlockOperation<Success, Failure>) -> Void
+ public typealias ThrowingExecutionBlock = () throws -> Success
+
+ private var executionBlock: ExecutionBlock?
+ private var cancellationBlocks: [() -> Void] = []
+
+ public convenience init(
+ dispatchQueue: DispatchQueue? = nil,
+ executionBlock: ExecutionBlock? = nil
+ ) {
+ self.init(
+ dispatchQueue: dispatchQueue,
+ executionBlock: executionBlock,
+ completionQueue: nil,
+ completionHandler: nil
+ )
+ }
+
+ public convenience init(
+ dispatchQueue: DispatchQueue? = nil,
+ executionBlock: @escaping ThrowingExecutionBlock
+ ) {
+ self.init(
+ dispatchQueue: dispatchQueue,
+ executionBlock: Self.wrapThrowingBlock(executionBlock),
+ completionQueue: nil,
+ completionHandler: nil
+ )
+ }
+
+ public init(
+ dispatchQueue: DispatchQueue?,
+ executionBlock: ExecutionBlock?,
+ completionQueue: DispatchQueue?,
+ completionHandler: CompletionHandler?
+ ) {
+ self.executionBlock = executionBlock
+
+ super.init(
+ dispatchQueue: dispatchQueue,
+ completionQueue: completionQueue,
+ completionHandler: completionHandler
+ )
+ }
+
+ override public func main() {
+ let block = executionBlock
+ executionBlock = nil
+
+ block?(self)
+ }
+
+ override public func operationDidCancel() {
+ let blocks = cancellationBlocks
+ cancellationBlocks.removeAll()
+
+ for block in blocks {
+ block()
+ }
+ }
+
+ override public func operationDidFinish() {
+ cancellationBlocks.removeAll()
+ executionBlock = nil
+ }
+
+ public func setExecutionBlock(
+ _ block: @escaping (ResultBlockOperation<Success, Failure>)
+ -> Void
+ ) {
+ dispatchQueue.async {
+ assert(!self.isExecuting && !self.isFinished)
+ self.executionBlock = block
+ }
+ }
+
+ public func setExecutionBlock(_ block: @escaping ThrowingExecutionBlock) {
+ setExecutionBlock(Self.wrapThrowingBlock(block))
+ }
+
+ public func addCancellationBlock(_ block: @escaping () -> Void) {
+ dispatchQueue.async {
+ if self.isCancelled {
+ block()
+ } else {
+ self.cancellationBlocks.append(block)
+ }
+ }
+ }
+
+ private class func wrapThrowingBlock(_ executionBlock: @escaping ThrowingExecutionBlock)
+ -> ExecutionBlock
+ {
+ return { operation in
+ do {
+ let value = try executionBlock()
+
+ operation.finish(completion: .success(value))
+ } catch {
+ let castedError = error as! Failure
+
+ operation.finish(completion: .failure(castedError))
+ }
+ }
+ }
+}
diff --git a/ios/Operations/ResultOperation+Output.swift b/ios/Operations/ResultOperation+Output.swift
new file mode 100644
index 0000000000..56b13a0524
--- /dev/null
+++ b/ios/Operations/ResultOperation+Output.swift
@@ -0,0 +1,15 @@
+//
+// ResultOperation+Output.swift
+// MullvadVPN
+//
+// Created by pronebird on 31/05/2022.
+// Copyright © 2022 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+
+extension ResultOperation: OutputOperation {
+ public var output: Success? {
+ return completion?.value
+ }
+}
diff --git a/ios/Operations/ResultOperation.swift b/ios/Operations/ResultOperation.swift
new file mode 100644
index 0000000000..4f88ce769b
--- /dev/null
+++ b/ios/Operations/ResultOperation.swift
@@ -0,0 +1,131 @@
+//
+// ResultOperation.swift
+// MullvadVPN
+//
+// Created by pronebird on 23/03/2022.
+// Copyright © 2022 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+
+/// Base class for operations producing result.
+open class ResultOperation<Success, Failure: Error>: AsyncOperation {
+ public typealias Completion = OperationCompletion<Success, Failure>
+ public typealias CompletionHandler = (Completion) -> Void
+
+ private let nslock = NSLock()
+ private var completionValue: Completion?
+ private var _completionQueue: DispatchQueue?
+ private var _completionHandler: CompletionHandler?
+ private var pendingFinish = false
+
+ public var completion: Completion? {
+ nslock.lock()
+ defer { nslock.unlock() }
+ return completionValue
+ }
+
+ public var completionQueue: DispatchQueue? {
+ get {
+ nslock.lock()
+ defer { nslock.unlock() }
+
+ return _completionQueue
+ }
+ set {
+ nslock.lock()
+ _completionQueue = newValue
+ nslock.unlock()
+ }
+ }
+
+ public var completionHandler: CompletionHandler? {
+ get {
+ nslock.lock()
+ defer { nslock.unlock() }
+
+ return _completionHandler
+ }
+ set {
+ nslock.lock()
+ defer { nslock.unlock() }
+ if !pendingFinish {
+ _completionHandler = newValue
+ }
+ }
+ }
+
+ override public init(dispatchQueue: DispatchQueue?) {
+ super.init(dispatchQueue: dispatchQueue)
+ }
+
+ public init(
+ dispatchQueue: DispatchQueue?,
+ completionQueue: DispatchQueue?,
+ completionHandler: CompletionHandler?
+ ) {
+ _completionQueue = completionQueue
+ _completionHandler = completionHandler
+
+ super.init(dispatchQueue: dispatchQueue)
+ }
+
+ @available(*, unavailable)
+ override public func finish() {
+ _finish(error: nil)
+ }
+
+ @available(*, unavailable)
+ override public func finish(error: Error?) {
+ _finish(error: error)
+ }
+
+ open func finish(completion: Completion) {
+ nslock.lock()
+ if completionValue == nil {
+ completionValue = completion
+ }
+ nslock.unlock()
+
+ _finish(error: completion.error)
+ }
+
+ private func _finish(error: Error?) {
+ nslock.lock()
+ // Bail if operation is already finishing.
+ guard !pendingFinish else {
+ nslock.unlock()
+ return
+ }
+
+ // Mark that operation is pending finish.
+ pendingFinish = true
+
+ // Copy completion handler.
+ let completionHandler = _completionHandler
+
+ // Unset completion handler.
+ _completionHandler = nil
+
+ // Copy completion value.
+ let completion = completionValue ?? .cancelled
+
+ // Copy completion queue.
+ let completionQueue = _completionQueue
+ nslock.unlock()
+
+ let block = {
+ // Call completion handler.
+ completionHandler?(completion)
+
+ // Finish operation.
+ super.finish(error: error)
+ }
+
+ if let completionQueue = completionQueue {
+ completionQueue.async(execute: block)
+ } else {
+ block()
+ }
+ }
+}
diff --git a/ios/Operations/TransformOperation.swift b/ios/Operations/TransformOperation.swift
new file mode 100644
index 0000000000..71cac93f4b
--- /dev/null
+++ b/ios/Operations/TransformOperation.swift
@@ -0,0 +1,138 @@
+//
+// TransformOperation.swift
+// AsyncOperationQueueTest
+//
+// Created by pronebird on 31/05/2022.
+//
+
+import Foundation
+
+public final class TransformOperation<Input, Output, Failure: Error>:
+ ResultOperation<Output, Failure>,
+ InputOperation
+{
+ public typealias ExecutionBlock = (Input, TransformOperation<Input, Output, Failure>) -> Void
+ public typealias ThrowingExecutionBlock = (Input) throws -> Output
+ public typealias InputBlock = () -> Input?
+
+ private let nslock = NSLock()
+
+ public var input: Input? {
+ return _input
+ }
+
+ private var __input: Input?
+ private var _input: Input? {
+ get {
+ nslock.lock()
+ defer { nslock.unlock() }
+ return __input
+ }
+ set {
+ nslock.lock()
+ __input = newValue
+ nslock.unlock()
+ }
+ }
+
+ private var inputBlock: InputBlock?
+
+ private var executionBlock: ExecutionBlock?
+ private var cancellationBlocks: [() -> Void] = []
+
+ public init(
+ dispatchQueue: DispatchQueue? = nil,
+ input: Input? = nil,
+ block: ExecutionBlock? = nil
+ ) {
+ __input = input
+ executionBlock = block
+
+ super.init(dispatchQueue: dispatchQueue)
+ }
+
+ public init(
+ dispatchQueue: DispatchQueue? = nil,
+ input: Input? = nil,
+ throwingBlock: @escaping ThrowingExecutionBlock
+ ) {
+ __input = input
+ executionBlock = Self.wrapThrowingBlock(throwingBlock)
+
+ super.init(dispatchQueue: dispatchQueue)
+ }
+
+ override public func main() {
+ let inputValue = inputBlock?()
+
+ _input = inputValue
+
+ guard let inputValue = inputValue, let executionBlock = executionBlock else {
+ finish(completion: .cancelled)
+ return
+ }
+
+ executionBlock(inputValue, self)
+ }
+
+ override public func operationDidCancel() {
+ let blocks = cancellationBlocks
+ cancellationBlocks.removeAll()
+
+ for block in blocks {
+ block()
+ }
+ }
+
+ override public func operationDidFinish() {
+ cancellationBlocks.removeAll()
+ executionBlock = nil
+ }
+
+ // MARK: - Block handlers
+
+ public func setExecutionBlock(_ block: @escaping ExecutionBlock) {
+ dispatchQueue.async {
+ assert(!self.isExecuting && !self.isFinished)
+ self.executionBlock = block
+ }
+ }
+
+ public func setExecutionBlock(_ block: @escaping ThrowingExecutionBlock) {
+ setExecutionBlock(Self.wrapThrowingBlock(block))
+ }
+
+ public func addCancellationBlock(_ block: @escaping () -> Void) {
+ dispatchQueue.async {
+ if self.isCancelled {
+ block()
+ } else {
+ self.cancellationBlocks.append(block)
+ }
+ }
+ }
+
+ // MARK: - Input injection
+
+ public func setInputBlock(_ block: @escaping () -> Input?) {
+ dispatchQueue.async {
+ self.inputBlock = block
+ }
+ }
+
+ private class func wrapThrowingBlock(_ executionBlock: @escaping ThrowingExecutionBlock)
+ -> ExecutionBlock
+ {
+ return { input, operation in
+ do {
+ let value = try executionBlock(input)
+
+ operation.finish(completion: .success(value))
+ } catch {
+ let castedError = error as! Failure
+
+ operation.finish(completion: .failure(castedError))
+ }
+ }
+ }
+}