diff options
| author | Andrej Mihajlov <and@mullvad.net> | 2022-09-25 16:34:23 +0200 |
|---|---|---|
| committer | Andrej Mihajlov <and@mullvad.net> | 2022-09-26 16:35:38 +0200 |
| commit | f389956c2cd884df142adbf00ff2ac7e2f69c2b2 (patch) | |
| tree | 5f7d4869324760eb4ba0107c0356edc89efd1457 /ios/Operations | |
| parent | 2e83b1ca27ff243a615ff10c94c20840b38dfd45 (diff) | |
| download | mullvadvpn-f389956c2cd884df142adbf00ff2ac7e2f69c2b2.tar.xz mullvadvpn-f389956c2cd884df142adbf00ff2ac7e2f69c2b2.zip | |
Move AsyncOperation into Operations static library and add separate tests
Diffstat (limited to 'ios/Operations')
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)) + } + } + } +} |
