summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAndrej Mihajlov <and@mullvad.net>2022-05-31 14:05:45 +0200
committerAndrej Mihajlov <and@mullvad.net>2022-06-08 12:31:16 +0200
commit2c8cb8005d1ff97bccdc08f9e3be7657629b205c (patch)
treee37ae78b1ceb62746e65532e7a2eaeda8bd252ba
parent4486a98b4e974273f89c1de7d2654773bf091956 (diff)
downloadmullvadvpn-2c8cb8005d1ff97bccdc08f9e3be7657629b205c.tar.xz
mullvadvpn-2c8cb8005d1ff97bccdc08f9e3be7657629b205c.zip
Add custom operation queue, conditions and observers
-rw-r--r--ios/MullvadVPN.xcodeproj/project.pbxproj18
-rw-r--r--ios/MullvadVPN/Operations/AsyncOperation.swift266
-rw-r--r--ios/MullvadVPN/Operations/AsyncOperationQueue.swift101
-rw-r--r--ios/MullvadVPN/Operations/OperationCondition.swift107
-rw-r--r--ios/MullvadVPN/Operations/OperationObserver.swift63
-rw-r--r--ios/MullvadVPN/Operations/ResultOperation+Fallible.swift15
6 files changed, 548 insertions, 22 deletions
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj
index fad611075f..61d4e38245 100644
--- a/ios/MullvadVPN.xcodeproj/project.pbxproj
+++ b/ios/MullvadVPN.xcodeproj/project.pbxproj
@@ -8,6 +8,7 @@
/* Begin PBXBuildFile section */
5801C9A527A14B2A0031566A /* TunnelManagerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5801C9A427A14B2A0031566A /* TunnelManagerState.swift */; };
+ 58059DE228468255002B1049 /* ResultOperation+Fallible.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58059DE128468255002B1049 /* ResultOperation+Fallible.swift */; };
5806767C27048E9B00C858CB /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CE5E7B224146470008646E /* PacketTunnelProvider.swift */; };
5807483B27DB8A980020ECBF /* WireGuardKitTypes in Frameworks */ = {isa = PBXBuildFile; productRef = 5807483A27DB8A980020ECBF /* WireGuardKitTypes */; };
5807E2C02432038B00F5FF30 /* String+Split.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5807E2BF2432038B00F5FF30 /* String+Split.swift */; };
@@ -70,6 +71,7 @@
5835B7CC233B76CB0096D79F /* TunnelManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5835B7CB233B76CB0096D79F /* TunnelManager.swift */; };
5838318B27C40A3900000571 /* Pinger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5838318A27C40A3900000571 /* Pinger.swift */; };
583DA21425FA4B5C00318683 /* LocationDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 583DA21325FA4B5C00318683 /* LocationDataSource.swift */; };
+ 583E1E1B2848DE1C004838B3 /* ResultOperation+Fallible.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58059DE128468255002B1049 /* ResultOperation+Fallible.swift */; };
5840250122B1124600E4CFEC /* IPAddress+Codable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5840250022B1124600E4CFEC /* IPAddress+Codable.swift */; };
5840250222B1124600E4CFEC /* IPAddress+Codable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5840250022B1124600E4CFEC /* IPAddress+Codable.swift */; };
5840250422B11AB700E4CFEC /* MullvadEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5840250322B11AB700E4CFEC /* MullvadEndpoint.swift */; };
@@ -194,6 +196,9 @@
5896AE86246D6AD8005B36CB /* CustomDateComponentsFormattingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5896AE85246D6AD8005B36CB /* CustomDateComponentsFormattingTests.swift */; };
5896AE88246D7FAF005B36CB /* CustomDateComponentsFormatting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5896AE83246D5889005B36CB /* CustomDateComponentsFormatting.swift */; };
5896CEF226972DEB00B0FAE8 /* AccountContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5896CEF126972DEB00B0FAE8 /* AccountContentView.swift */; };
+ 589D287A2846250500F9A7B3 /* OperationCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 589D28772846250500F9A7B3 /* OperationCondition.swift */; };
+ 589D287B2846250500F9A7B3 /* AsyncOperationQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 589D28782846250500F9A7B3 /* AsyncOperationQueue.swift */; };
+ 589D287C2846250500F9A7B3 /* OperationObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 589D28792846250500F9A7B3 /* OperationObserver.swift */; };
58A1AA8C23F5584C009F7EA6 /* ConnectionPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58A1AA8B23F5584B009F7EA6 /* ConnectionPanelView.swift */; };
58A8055E2716EA6700681642 /* AnyIPAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 584D26BE270C550B004EA533 /* AnyIPAddress.swift */; };
58A8BE81239FBE62006B74AC /* IPEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58561C98239A5D1500BD6B5E /* IPEndpoint.swift */; };
@@ -327,6 +332,7 @@
/* Begin PBXFileReference section */
5801C9A427A14B2A0031566A /* TunnelManagerState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelManagerState.swift; sourceTree = "<group>"; };
+ 58059DE128468255002B1049 /* ResultOperation+Fallible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ResultOperation+Fallible.swift"; sourceTree = "<group>"; };
5807E2BF2432038B00F5FF30 /* String+Split.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+Split.swift"; sourceTree = "<group>"; };
5807E2C1243203D000F5FF30 /* StringTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StringTests.swift; sourceTree = "<group>"; };
5808273928487E3E006B77A4 /* Base.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Base.xcconfig; sourceTree = "<group>"; };
@@ -463,6 +469,9 @@
5896AE83246D5889005B36CB /* CustomDateComponentsFormatting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDateComponentsFormatting.swift; sourceTree = "<group>"; };
5896AE85246D6AD8005B36CB /* CustomDateComponentsFormattingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDateComponentsFormattingTests.swift; sourceTree = "<group>"; };
5896CEF126972DEB00B0FAE8 /* AccountContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountContentView.swift; sourceTree = "<group>"; };
+ 589D28772846250500F9A7B3 /* OperationCondition.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OperationCondition.swift; sourceTree = "<group>"; };
+ 589D28782846250500F9A7B3 /* AsyncOperationQueue.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AsyncOperationQueue.swift; sourceTree = "<group>"; };
+ 589D28792846250500F9A7B3 /* OperationObserver.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OperationObserver.swift; sourceTree = "<group>"; };
58A1AA8623F43901009F7EA6 /* Location.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Location.swift; sourceTree = "<group>"; };
58A1AA8B23F5584B009F7EA6 /* ConnectionPanelView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConnectionPanelView.swift; sourceTree = "<group>"; };
58A94AE326CFD945001CB97C /* TunnelErrorNotificationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelErrorNotificationProvider.swift; sourceTree = "<group>"; };
@@ -608,11 +617,15 @@
580EE22324B3243100F9D8A1 /* AsyncBlockOperation.swift */,
58E973DD24850EB600096F90 /* AsyncOperation.swift */,
580EE20524B3222200F9D8A1 /* ExclusivityController.swift */,
+ 589D28782846250500F9A7B3 /* AsyncOperationQueue.swift */,
5840BE34279EDB16002836BA /* OperationCompletion.swift */,
+ 589D28772846250500F9A7B3 /* OperationCondition.swift */,
+ 589D28792846250500F9A7B3 /* OperationObserver.swift */,
5820675D26E6839900655B05 /* PresentAlertOperation.swift */,
5846226426E0D9630035F7C2 /* ProductsRequestOperation.swift */,
58F7D26427EB50A300E4D821 /* ResultOperation.swift */,
5842102D282D3FC200F24E46 /* ResultBlockOperation.swift */,
+ 58059DE128468255002B1049 /* ResultOperation+Fallible.swift */,
);
path = Operations;
sourceTree = "<group>";
@@ -1208,6 +1221,7 @@
585DA8A326B14E0D00B8C587 /* ServerRelaysResponse.swift in Sources */,
5820676226E75D8500655B05 /* REST.swift in Sources */,
58A8055E2716EA6700681642 /* AnyIPAddress.swift in Sources */,
+ 583E1E1B2848DE1C004838B3 /* ResultOperation+Fallible.swift in Sources */,
5857F23024C843ED00CF6F47 /* ChainedError.swift in Sources */,
58A8BE81239FBE62006B74AC /* IPEndpoint.swift in Sources */,
5846227A26E24F1F0035F7C2 /* ExclusivityController.swift in Sources */,
@@ -1242,10 +1256,12 @@
58BA693123EADA6A009DC256 /* SimulatorTunnelProvider.swift in Sources */,
58F1311327E09B00007AC5BC /* Cancellable.swift in Sources */,
587C575326D2615F005EF767 /* PacketTunnelOptions.swift in Sources */,
+ 589D287C2846250500F9A7B3 /* OperationObserver.swift in Sources */,
587B753B2666467500DEF7E9 /* NotificationBannerView.swift in Sources */,
58B993B12608A34500BA7811 /* LoginContentView.swift in Sources */,
58E6771F24ADFE7800AA26E7 /* SettingsNavigationController.swift in Sources */,
58A1AA8C23F5584C009F7EA6 /* ConnectionPanelView.swift in Sources */,
+ 58059DE228468255002B1049 /* ResultOperation+Fallible.swift in Sources */,
582BB1B3229574F40055B6EF /* SettingsAccountCell.swift in Sources */,
588527B2276B3F0700BAA373 /* LoadTunnelConfigurationOperation.swift in Sources */,
58F1311527E0B2AB007AC5BC /* Result+Extensions.swift in Sources */,
@@ -1272,6 +1288,7 @@
58655DCE27DA0A5D00911834 /* TunnelMonitorConfiguration.swift in Sources */,
5850367F25A481D800A43E93 /* IPAddressRange+Codable.swift in Sources */,
58F2E14C276A61C000A79513 /* RotateKeyOperation.swift in Sources */,
+ 589D287A2846250500F9A7B3 /* OperationCondition.swift in Sources */,
5871FB96254ADE4E0051A0A4 /* ConsolidatedApplicationLog.swift in Sources */,
58FEEB58260B662E00A621A8 /* AutomaticKeyboardResponder.swift in Sources */,
5846227326E22A160035F7C2 /* AppStorePaymentObserver.swift in Sources */,
@@ -1397,6 +1414,7 @@
58FD5BF22424F7D700112C88 /* UserInterfaceInteractionRestriction.swift in Sources */,
5811DE50239014550011EB53 /* NEVPNStatus+Debug.swift in Sources */,
58C3A4B222456F1B00340BDB /* AccountInputGroupView.swift in Sources */,
+ 589D287B2846250500F9A7B3 /* AsyncOperationQueue.swift in Sources */,
58F840B22464491D0044E708 /* ChainedError.swift in Sources */,
588BCF24280FE43D009ADCEC /* RESTDevicesProxy.swift in Sources */,
58ACF64B26553C3F00ACE4B7 /* SettingsSwitchCell.swift in Sources */,
diff --git a/ios/MullvadVPN/Operations/AsyncOperation.swift b/ios/MullvadVPN/Operations/AsyncOperation.swift
index 898fc4b297..d3e13d2f81 100644
--- a/ios/MullvadVPN/Operations/AsyncOperation.swift
+++ b/ios/MullvadVPN/Operations/AsyncOperation.swift
@@ -8,28 +8,82 @@
import Foundation
+@objc 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
class AsyncOperation: Operation {
+ private static var observerContext = 0
+
/// A state lock used for manipulating the operation state flags in a thread safe fashion.
private let stateLock = NSRecursiveLock()
- /// Operation state flags.
- private var _isExecuting = false
- private var _isFinished = false
+ /// Operation state.
+ @objc private var state: State = .initialized
private var _isCancelled = false
+ final override 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
+ }
+ }
+
final override var isExecuting: Bool {
stateLock.lock()
defer { stateLock.unlock() }
- return _isExecuting
+ return state == .executing
}
final override var isFinished: Bool {
stateLock.lock()
defer { stateLock.unlock() }
- return _isFinished
+ return state == .finished
}
final override var isCancelled: Bool {
@@ -43,14 +97,145 @@ class AsyncOperation: Operation {
return true
}
+ // MARK: - Observers
+
+ private var _observers: [OperationObserver] = []
+
+ final var observers: [OperationObserver] {
+ stateLock.lock()
+ defer { stateLock.unlock() }
+
+ return _observers
+ }
+
+ final func addObserver(_ observer: OperationObserver) {
+ stateLock.lock()
+ assert(state < .executing)
+ _observers.append(observer)
+ stateLock.unlock()
+ observer.didAttach(to: self)
+ }
+
+ // MARK: - Conditions
+
+ private var _conditions: [OperationCondition] = []
+
+ final var conditions: [OperationCondition] {
+ stateLock.lock()
+ defer { stateLock.unlock() }
+
+ return _conditions
+ }
+
+ func addCondition(_ condition: OperationCondition) {
+ stateLock.lock()
+ assert(state < .evaluatingConditions)
+ _conditions.append(condition)
+ stateLock.unlock()
+ }
+
+ private func evaluateConditions() {
+ guard !_conditions.isEmpty else {
+ setState(.ready)
+ return
+ }
+
+ setState(.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]) {
+ stateLock.lock()
+ defer { stateLock.unlock() }
+
+ guard state < .ready else { return }
+
+ let conditionsSatisfied = results.allSatisfy { $0 }
+ if !conditionsSatisfied {
+ cancel()
+ }
+
+ setState(.ready)
+ }
+
+ // MARK: -
+
let dispatchQueue: DispatchQueue
+
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
+
+ @objc class func keyPathsForValuesAffectingIsReady() -> Set<String> {
+ return ["state"]
+ }
+
+ @objc class func keyPathsForValuesAffectingIsExecuting() -> Set<String> {
+ return ["state"]
}
+ @objc class func keyPathsForValuesAffectingIsFinished() -> Set<String> {
+ return ["state"]
+ }
+
+ override func observeValue(
+ forKeyPath keyPath: String?,
+ of object: Any?,
+ change: [NSKeyValueChangeKey : Any]?,
+ context: UnsafeMutableRawPointer?
+ )
+ {
+ if context == &Self.observerContext {
+ checkReadiness()
+ } else {
+ super.observeValue(
+ forKeyPath: keyPath,
+ of: object,
+ change: change,
+ context: context
+ )
+ }
+ }
+
+ // MARK: - Lifecycle
+
final override func start() {
- let underlyingQueue = OperationQueue.current?.underlyingQueue
+ let currentQueue = OperationQueue.current
+ let underlyingQueue = currentQueue?.underlyingQueue
if underlyingQueue == dispatchQueue {
_start()
@@ -67,8 +252,13 @@ class AsyncOperation: Operation {
stateLock.unlock()
finish()
} else {
- setExecuting(true)
+ setState(.executing)
+
+ for observer in _observers {
+ observer.operationDidStart(self)
+ }
stateLock.unlock()
+
main()
}
}
@@ -95,6 +285,10 @@ class AsyncOperation: Operation {
if notifyDidCancel {
dispatchQueue.async {
self.operationDidCancel()
+
+ for observer in self.observers {
+ observer.operationDidCancel(self)
+ }
}
}
}
@@ -103,34 +297,46 @@ class AsyncOperation: Operation {
var notifyDidFinish = false
stateLock.lock()
-
- if _isExecuting {
- setExecuting(false)
- }
-
- if !_isFinished {
- willChangeValue(for: \.isFinished)
- _isFinished = true
- didChangeValue(for: \.isFinished)
-
+ if state < .finished {
+ setState(.finished)
notifyDidFinish = true
}
-
stateLock.unlock()
if notifyDidFinish {
dispatchQueue.async {
self.operationDidFinish()
+
+ for observer in self.observers {
+ observer.operationDidFinish(self)
+ }
}
}
}
- private func setExecuting(_ value: Bool) {
- willChangeValue(for: \.isExecuting)
- _isExecuting = value
- didChangeValue(for: \.isExecuting)
+ // MARK: - Private
+
+ private func setState(_ newState: State) {
+ willChangeValue(for: \.state)
+ assert(state < newState)
+ state = newState
+ didChangeValue(for: \.state)
+ }
+
+ private func checkReadiness() {
+ stateLock.lock()
+ if state == .pending && !_isCancelled && super.isReady {
+ evaluateConditions()
+ }
+ stateLock.unlock()
+ }
+
+ func didEnqueue() {
+ setState(.pending)
}
+ // MARK: - Subclass overrides
+
func operationDidCancel() {
// Override in subclasses.
}
@@ -147,3 +353,19 @@ extension Operation {
}
}
}
+
+extension Operation {
+ var operationName: String {
+ return name ?? "\(self)"
+ }
+}
+
+
+protocol OperationBlockObserverSupport {}
+extension AsyncOperation: OperationBlockObserverSupport {}
+
+extension OperationBlockObserverSupport where Self: AsyncOperation {
+ func addBlockObserver(_ observer: OperationBlockObserver<Self>) {
+ addObserver(observer)
+ }
+}
diff --git a/ios/MullvadVPN/Operations/AsyncOperationQueue.swift b/ios/MullvadVPN/Operations/AsyncOperationQueue.swift
new file mode 100644
index 0000000000..f89fd70841
--- /dev/null
+++ b/ios/MullvadVPN/Operations/AsyncOperationQueue.swift
@@ -0,0 +1,101 @@
+//
+// AsyncOperationQueue.swift
+// MullvadVPN
+//
+// Created by pronebird on 30/05/2022.
+// Copyright © 2022 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+
+class AsyncOperationQueue: OperationQueue {
+ override 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 func addOperations(_ operations: [Operation], waitUntilFinished wait: Bool) {
+ for operation in operations {
+ addOperation(operation)
+ }
+
+ if wait {
+ let blockOperation = BlockOperation()
+ blockOperation.addDependencies(operations)
+
+ addOperation(blockOperation)
+ blockOperation.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 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/MullvadVPN/Operations/OperationCondition.swift b/ios/MullvadVPN/Operations/OperationCondition.swift
new file mode 100644
index 0000000000..da3e5f56a1
--- /dev/null
+++ b/ios/MullvadVPN/Operations/OperationCondition.swift
@@ -0,0 +1,107 @@
+//
+// OperationCondition.swift
+// MullvadVPN
+//
+// Created by pronebird on 30/05/2022.
+// Copyright © 2022 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+
+protocol FallibleOperation {
+ var error: Error? { get }
+}
+
+protocol OperationCondition {
+ var name: String { get }
+ var isMutuallyExclusive: Bool { get }
+
+ func evaluate(for operation: Operation, completion: @escaping (Bool) -> Void)
+}
+
+final class NoCancelledDependenciesCondition: OperationCondition {
+ var name: String {
+ return "NoCancelledDependenciesCondition"
+ }
+
+ var isMutuallyExclusive: Bool {
+ return false
+ }
+
+ func evaluate(for operation: Operation, completion: @escaping (Bool) -> Void) {
+ let satisfy = operation.dependencies.allSatisfy { operation in
+ return !operation.isCancelled
+ }
+
+ completion(satisfy)
+ }
+}
+
+final class NoFailedDependenciesCondition: OperationCondition {
+ var name: String {
+ return "NoFailedDependenciesCondition"
+ }
+
+ var isMutuallyExclusive: Bool {
+ return false
+ }
+
+ let ignoreCancellations: Bool
+ init(ignoreCancellations: Bool) {
+ self.ignoreCancellations = ignoreCancellations
+ }
+
+ func evaluate(for operation: Operation, completion: @escaping (Bool) -> Void) {
+ let satisfy = operation.dependencies.allSatisfy { operation in
+ if let fallibleOperation = operation as? FallibleOperation,
+ fallibleOperation.error != nil {
+ return false
+ }
+
+ if operation.isCancelled && !self.ignoreCancellations {
+ return false
+ }
+
+ return true
+ }
+
+ completion(satisfy)
+ }
+}
+
+final class BlockCondition: OperationCondition {
+ typealias HandlerBlock = (Operation, @escaping (Bool) -> Void) -> Void
+
+ var name: String {
+ return "BlockCondition"
+ }
+
+ var isMutuallyExclusive: Bool {
+ return false
+ }
+
+ let block: HandlerBlock
+ init(block: @escaping HandlerBlock) {
+ self.block = block
+ }
+
+ func evaluate(for operation: Operation, completion: @escaping (Bool) -> Void) {
+ block(operation, completion)
+ }
+}
+
+final class MutuallyExclusive: OperationCondition {
+ let name: String
+
+ var isMutuallyExclusive: Bool {
+ return true
+ }
+
+ init(category: String) {
+ name = "MutuallyExclusive<\(category)>"
+ }
+
+ func evaluate(for operation: Operation, completion: @escaping (Bool) -> Void) {
+ completion(true)
+ }
+}
diff --git a/ios/MullvadVPN/Operations/OperationObserver.swift b/ios/MullvadVPN/Operations/OperationObserver.swift
new file mode 100644
index 0000000000..28ce7ee348
--- /dev/null
+++ b/ios/MullvadVPN/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
+
+protocol OperationObserver {
+ func didAttach(to operation: Operation)
+ func operationDidStart(_ operation: Operation)
+ func operationDidCancel(_ operation: Operation)
+ func operationDidFinish(_ operation: Operation)
+}
+
+/// Block based operation observer.
+class OperationBlockObserver<OperationType: Operation>: OperationObserver {
+ typealias VoidBlock = (OperationType) -> Void
+
+ private let _didAttach: VoidBlock?
+ private let _didStart: VoidBlock?
+ private let _didCancel: VoidBlock?
+ private let _didFinish: VoidBlock?
+
+ init(
+ didAttach: VoidBlock? = nil,
+ didStart: VoidBlock? = nil,
+ didCancel: VoidBlock? = nil,
+ didFinish: VoidBlock? = nil
+ )
+ {
+ _didAttach = didAttach
+ _didStart = didStart
+ _didCancel = didCancel
+ _didFinish = didFinish
+ }
+
+ func didAttach(to operation: Operation) {
+ if let operation = operation as? OperationType {
+ _didAttach?(operation)
+ }
+ }
+
+ func operationDidStart(_ operation: Operation) {
+ if let operation = operation as? OperationType {
+ _didStart?(operation)
+ }
+ }
+
+ func operationDidCancel(_ operation: Operation) {
+ if let operation = operation as? OperationType {
+ _didCancel?(operation)
+ }
+ }
+
+ func operationDidFinish(_ operation: Operation) {
+ if let operation = operation as? OperationType {
+ _didFinish?(operation)
+ }
+ }
+}
diff --git a/ios/MullvadVPN/Operations/ResultOperation+Fallible.swift b/ios/MullvadVPN/Operations/ResultOperation+Fallible.swift
new file mode 100644
index 0000000000..fac45cbf93
--- /dev/null
+++ b/ios/MullvadVPN/Operations/ResultOperation+Fallible.swift
@@ -0,0 +1,15 @@
+//
+// ResultOperation+Fallible.swift
+// MullvadVPN
+//
+// Created by pronebird on 31/05/2022.
+// Copyright © 2022 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+
+extension ResultOperation: FallibleOperation {
+ var error: Error? {
+ return completion?.error
+ }
+}