summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorEmīls <emils@mullvad.net>2023-11-15 15:41:01 +0100
committerEmīls <emils@mullvad.net>2023-11-15 15:41:01 +0100
commit2baf3f017ca48bc079720e2c942e2f04289fcf08 (patch)
tree9fc4c6153d799d6d9fdf756b361b70427069802b
parent4557fa02a2f69ce90f47829e87baa357dd6edce0 (diff)
parent5f3d91c9f9cd4235c0d51715fe8c3a5ef23cb3b8 (diff)
downloadmullvadvpn-2baf3f017ca48bc079720e2c942e2f04289fcf08.tar.xz
mullvadvpn-2baf3f017ca48bc079720e2c942e2f04289fcf08.zip
Merge branch 'show-outgoing-address-in-connection-view-ios-301'
-rw-r--r--ios/MullvadVPN.xcodeproj/project.pbxproj54
-rw-r--r--ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift8
-rw-r--r--ios/MullvadVPN/Coordinators/TunnelCoordinator.swift10
-rw-r--r--ios/MullvadVPN/GeneralAPIs/OutgoingConnectionData.swift28
-rw-r--r--ios/MullvadVPN/GeneralAPIs/OutgoingConnectionProxy.swift94
-rw-r--r--ios/MullvadVPN/SceneDelegate.swift3
-rw-r--r--ios/MullvadVPN/UI appearance/UIMetrics.swift5
-rw-r--r--ios/MullvadVPN/View controllers/Tunnel/ConnectionPanelView.swift10
-rw-r--r--ios/MullvadVPN/View controllers/Tunnel/OutgoingConnectionService.swift44
-rw-r--r--ios/MullvadVPN/View controllers/Tunnel/TunnelControlView.swift13
-rw-r--r--ios/MullvadVPN/View controllers/Tunnel/TunnelViewController.swift5
-rw-r--r--ios/MullvadVPN/View controllers/Tunnel/TunnelViewControllerInteractor.swift30
-rw-r--r--ios/MullvadVPNTests/MockURLProtocol.swift55
-rw-r--r--ios/MullvadVPNTests/OutgoingConnectionProxy+Stub.swift39
-rw-r--r--ios/MullvadVPNTests/OutgoingConnectionProxyTests.swift127
-rw-r--r--ios/MullvadVPNTests/OutgoingConnectionServiceTests.swift49
-rw-r--r--ios/MullvadVPNTests/XCTest+Async.swift27
17 files changed, 585 insertions, 16 deletions
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj
index 3009059893..ffb67a06f1 100644
--- a/ios/MullvadVPN.xcodeproj/project.pbxproj
+++ b/ios/MullvadVPN.xcodeproj/project.pbxproj
@@ -650,10 +650,21 @@
F09A297C2A9F8A9B00EA3B6F /* VoucherTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = F09A29792A9F8A9B00EA3B6F /* VoucherTextField.swift */; };
F09A297D2A9F8A9B00EA3B6F /* RedeemVoucherContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F09A297A2A9F8A9B00EA3B6F /* RedeemVoucherContentView.swift */; };
F09A29822A9F8AD200EA3B6F /* RedeemVoucherInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F09A297F2A9F8AD200EA3B6F /* RedeemVoucherInteractor.swift */; };
+ F09D04B32AE919AC003D4F89 /* OutgoingConnectionProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = F09D04AF2AE7F83D003D4F89 /* OutgoingConnectionProxy.swift */; };
+ F09D04B52AE93CB6003D4F89 /* OutgoingConnectionProxy+Stub.swift in Sources */ = {isa = PBXBuildFile; fileRef = F09D04B42AE93CB6003D4F89 /* OutgoingConnectionProxy+Stub.swift */; };
+ F09D04B72AE941DA003D4F89 /* OutgoingConnectionProxyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F09D04B62AE941DA003D4F89 /* OutgoingConnectionProxyTests.swift */; };
+ F09D04B92AE95111003D4F89 /* OutgoingConnectionProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = F09D04AF2AE7F83D003D4F89 /* OutgoingConnectionProxy.swift */; };
+ F09D04BB2AE95396003D4F89 /* MockURLProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = F09D04BA2AE95396003D4F89 /* MockURLProtocol.swift */; };
+ F09D04BD2AEBB7C5003D4F89 /* OutgoingConnectionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F09D04BC2AEBB7C5003D4F89 /* OutgoingConnectionService.swift */; };
+ F09D04C02AF39D63003D4F89 /* OutgoingConnectionServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F09D04BF2AF39D63003D4F89 /* OutgoingConnectionServiceTests.swift */; };
+ F09D04C12AF39EA2003D4F89 /* OutgoingConnectionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F09D04BC2AEBB7C5003D4F89 /* OutgoingConnectionService.swift */; };
+ F0B0E6972AFE6E7E001DC66B /* XCTest+Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B0E6962AFE6E7E001DC66B /* XCTest+Async.swift */; };
F0C2AEFD2A0BB5CC00986207 /* NotificationProviderIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0C2AEFC2A0BB5CC00986207 /* NotificationProviderIdentifier.swift */; };
F0C6A8432AB08E54000777A8 /* RedeemVoucherViewConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0C6A8422AB08E54000777A8 /* RedeemVoucherViewConfiguration.swift */; };
F0C6FA812A66E23300F521F0 /* DeleteAccountOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0C6FA802A66E23300F521F0 /* DeleteAccountOperation.swift */; };
F0C6FA852A6A733700F521F0 /* InAppPurchaseInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0C6FA842A6A733700F521F0 /* InAppPurchaseInteractor.swift */; };
+ F0D8825B2B04F53600D3EF9A /* OutgoingConnectionData.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0D8825A2B04F53600D3EF9A /* OutgoingConnectionData.swift */; };
+ F0D8825C2B04F70E00D3EF9A /* OutgoingConnectionData.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0D8825A2B04F53600D3EF9A /* OutgoingConnectionData.swift */; };
F0DA87472A9CB9A2006044F1 /* AccountExpiryRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0DA87462A9CB9A2006044F1 /* AccountExpiryRow.swift */; };
F0DA87492A9CBA9F006044F1 /* AccountDeviceRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0DA87482A9CBA9F006044F1 /* AccountDeviceRow.swift */; };
F0DA874B2A9CBACB006044F1 /* AccountNumberRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0DA874A2A9CBACB006044F1 /* AccountNumberRow.swift */; };
@@ -1636,10 +1647,18 @@
F09A29792A9F8A9B00EA3B6F /* VoucherTextField.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VoucherTextField.swift; sourceTree = "<group>"; };
F09A297A2A9F8A9B00EA3B6F /* RedeemVoucherContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RedeemVoucherContentView.swift; sourceTree = "<group>"; };
F09A297F2A9F8AD200EA3B6F /* RedeemVoucherInteractor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RedeemVoucherInteractor.swift; sourceTree = "<group>"; };
+ F09D04AF2AE7F83D003D4F89 /* OutgoingConnectionProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutgoingConnectionProxy.swift; sourceTree = "<group>"; };
+ F09D04B42AE93CB6003D4F89 /* OutgoingConnectionProxy+Stub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OutgoingConnectionProxy+Stub.swift"; sourceTree = "<group>"; };
+ F09D04B62AE941DA003D4F89 /* OutgoingConnectionProxyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutgoingConnectionProxyTests.swift; sourceTree = "<group>"; };
+ F09D04BA2AE95396003D4F89 /* MockURLProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockURLProtocol.swift; sourceTree = "<group>"; };
+ F09D04BC2AEBB7C5003D4F89 /* OutgoingConnectionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutgoingConnectionService.swift; sourceTree = "<group>"; };
+ F09D04BF2AF39D63003D4F89 /* OutgoingConnectionServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutgoingConnectionServiceTests.swift; sourceTree = "<group>"; };
+ F0B0E6962AFE6E7E001DC66B /* XCTest+Async.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCTest+Async.swift"; sourceTree = "<group>"; };
F0C2AEFC2A0BB5CC00986207 /* NotificationProviderIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationProviderIdentifier.swift; sourceTree = "<group>"; };
F0C6A8422AB08E54000777A8 /* RedeemVoucherViewConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedeemVoucherViewConfiguration.swift; sourceTree = "<group>"; };
F0C6FA802A66E23300F521F0 /* DeleteAccountOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteAccountOperation.swift; sourceTree = "<group>"; };
F0C6FA842A6A733700F521F0 /* InAppPurchaseInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppPurchaseInteractor.swift; sourceTree = "<group>"; };
+ F0D8825A2B04F53600D3EF9A /* OutgoingConnectionData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutgoingConnectionData.swift; sourceTree = "<group>"; };
F0DA87462A9CB9A2006044F1 /* AccountExpiryRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountExpiryRow.swift; sourceTree = "<group>"; };
F0DA87482A9CBA9F006044F1 /* AccountDeviceRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDeviceRow.swift; sourceTree = "<group>"; };
F0DA874A2A9CBACB006044F1 /* AccountNumberRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountNumberRow.swift; sourceTree = "<group>"; };
@@ -2128,14 +2147,15 @@
583FE01E29C197D5006E85F9 /* Tunnel */ = {
isa = PBXGroup;
children = (
- 58B43C1825F77DB60002C8C3 /* TunnelControlView.swift */,
- 58C3F4F82964B08300D72515 /* MapViewController.swift */,
58A1AA8B23F5584B009F7EA6 /* ConnectionPanelView.swift */,
- 58CCA00F224249A1004F3011 /* TunnelViewController.swift */,
5878A27A2909649A0096FC88 /* CustomOverlayRenderer.swift */,
- 5878A278290954790096FC88 /* TunnelViewControllerInteractor.swift */,
58907D9424D17B4E00CFC3F5 /* DisconnectSplitButton.swift */,
+ 58C3F4F82964B08300D72515 /* MapViewController.swift */,
+ F09D04BC2AEBB7C5003D4F89 /* OutgoingConnectionService.swift */,
5862805322428EF100F5A6E1 /* TranslucentButtonBlurView.swift */,
+ 58B43C1825F77DB60002C8C3 /* TunnelControlView.swift */,
+ 58CCA00F224249A1004F3011 /* TunnelViewController.swift */,
+ 5878A278290954790096FC88 /* TunnelViewControllerInteractor.swift */,
);
path = Tunnel;
sourceTree = "<group>";
@@ -2493,6 +2513,10 @@
F07BF2572A26112D00042943 /* InputTextFormatterTests.swift */,
A9B6AC172ADE8F4300F7802A /* MigrationManagerTests.swift */,
58C3FA652A38549D006A450A /* MockFileCache.swift */,
+ F09D04BA2AE95396003D4F89 /* MockURLProtocol.swift */,
+ F09D04B42AE93CB6003D4F89 /* OutgoingConnectionProxy+Stub.swift */,
+ F09D04B62AE941DA003D4F89 /* OutgoingConnectionProxyTests.swift */,
+ F09D04BF2AF39D63003D4F89 /* OutgoingConnectionServiceTests.swift */,
A9467E7E2A29DEFE000DC21F /* RelayCacheTests.swift */,
A9C342C22ACC3EE90045F00E /* RelayCacheTracker+Stubs.swift */,
584B26F3237434D00073B10E /* RelaySelectorTests.swift */,
@@ -2503,6 +2527,7 @@
A9E0317B2ACBFC7E0095D843 /* TunnelStore+Stubs.swift */,
A9E031792ACB0AE70095D843 /* UIApplication+Stubs.swift */,
58165EBD2A262CBB00688EAD /* WgKeyRotationTests.swift */,
+ F0B0E6962AFE6E7E001DC66B /* XCTest+Async.swift */,
);
path = MullvadVPNTests;
sourceTree = "<group>";
@@ -2737,6 +2762,7 @@
58C774C929AB543C003A1A56 /* Containers */,
58CAF9F22983D32200BE19F7 /* Coordinators */,
583FE02329C1AC9F006E85F9 /* Extensions */,
+ F09D04B82AE94F27003D4F89 /* GeneralAPIs */,
58B26E1F2943516500D5980C /* Notifications */,
586A950B2901250A007BAF2B /* Operations */,
5864859729A0D012006C5743 /* Presentation controllers */,
@@ -3018,6 +3044,15 @@
path = RedeemVoucher;
sourceTree = "<group>";
};
+ F09D04B82AE94F27003D4F89 /* GeneralAPIs */ = {
+ isa = PBXGroup;
+ children = (
+ F0D8825A2B04F53600D3EF9A /* OutgoingConnectionData.swift */,
+ F09D04AF2AE7F83D003D4F89 /* OutgoingConnectionProxy.swift */,
+ );
+ path = GeneralAPIs;
+ sourceTree = "<group>";
+ };
F0E361892A4ADCF500AEEF2B /* Welcome */ = {
isa = PBXGroup;
children = (
@@ -4095,15 +4130,19 @@
A9B6AC1B2ADEA3AD00F7802A /* MemoryCache.swift in Sources */,
A9A5F9EA2ACB05160083449F /* Bundle+ProductVersion.swift in Sources */,
A9A5F9EB2ACB05160083449F /* CharacterSet+IPAddress.swift in Sources */,
+ F0D8825C2B04F70E00D3EF9A /* OutgoingConnectionData.swift in Sources */,
A9A5F9EC2ACB05160083449F /* CodingErrors+CustomErrorDescription.swift in Sources */,
A9A5F9ED2ACB05160083449F /* NSRegularExpression+IPAddress.swift in Sources */,
A9A5F9EE2ACB05160083449F /* RESTCreateApplePaymentResponse+Localization.swift in Sources */,
A9A5F9EF2ACB05160083449F /* String+AccountFormatting.swift in Sources */,
A9A5F9F02ACB05160083449F /* String+FuzzyMatch.swift in Sources */,
+ F09D04C12AF39EA2003D4F89 /* OutgoingConnectionService.swift in Sources */,
A9A5F9F12ACB05160083449F /* String+Split.swift in Sources */,
A9A5F9F22ACB05160083449F /* NotificationConfiguration.swift in Sources */,
A9A5F9F32ACB05160083449F /* AccountExpirySystemNotificationProvider.swift in Sources */,
A9A5F9F52ACB05160083449F /* RegisteredDeviceInAppNotificationProvider.swift in Sources */,
+ F09D04B72AE941DA003D4F89 /* OutgoingConnectionProxyTests.swift in Sources */,
+ F09D04B92AE95111003D4F89 /* OutgoingConnectionProxy.swift in Sources */,
A9A5F9F62ACB05160083449F /* TunnelStatusNotificationProvider.swift in Sources */,
A9A5F9F72ACB05160083449F /* NotificationProviderProtocol.swift in Sources */,
A9A5F9F82ACB05160083449F /* NotificationProviderIdentifier.swift in Sources */,
@@ -4144,12 +4183,14 @@
A9A5FA142ACB05160083449F /* MapConnectionStatusOperation.swift in Sources */,
A9A5FA152ACB05160083449F /* RedeemVoucherOperation.swift in Sources */,
A9A5FA162ACB05160083449F /* RotateKeyOperation.swift in Sources */,
+ F09D04B52AE93CB6003D4F89 /* OutgoingConnectionProxy+Stub.swift in Sources */,
A9A5FA172ACB05160083449F /* SendTunnelProviderMessageOperation.swift in Sources */,
A9A5FA182ACB05160083449F /* SetAccountOperation.swift in Sources */,
A9A5FA192ACB05160083449F /* StartTunnelOperation.swift in Sources */,
A9A5FA1A2ACB05160083449F /* StopTunnelOperation.swift in Sources */,
A9A5FA1B2ACB05160083449F /* Tunnel.swift in Sources */,
A9A5FA1C2ACB05160083449F /* Tunnel+Messaging.swift in Sources */,
+ F09D04BB2AE95396003D4F89 /* MockURLProtocol.swift in Sources */,
A9A5FA1D2ACB05160083449F /* TunnelBlockObserver.swift in Sources */,
A9A5FA1E2ACB05160083449F /* TunnelConfiguration.swift in Sources */,
A9A5FA1F2ACB05160083449F /* TunnelInteractor.swift in Sources */,
@@ -4159,6 +4200,7 @@
A9A5FA222ACB05160083449F /* TunnelObserver.swift in Sources */,
A988A3E22AFE54AC0008D2C7 /* AccountExpiry.swift in Sources */,
A9E0317F2ACC331C0095D843 /* TunnelStatusBlockObserver.swift in Sources */,
+ F09D04C02AF39D63003D4F89 /* OutgoingConnectionServiceTests.swift in Sources */,
A9A5FA232ACB05160083449F /* TunnelState.swift in Sources */,
A9A5FA242ACB05160083449F /* TunnelStore.swift in Sources */,
A9A5FA252ACB05160083449F /* UpdateAccountDataOperation.swift in Sources */,
@@ -4174,6 +4216,7 @@
A9A5FA2E2ACB05160083449F /* FileCacheTests.swift in Sources */,
A9A5FA2F2ACB05160083449F /* FixedWidthIntegerArithmeticsTests.swift in Sources */,
A9A5FA302ACB05160083449F /* InputTextFormatterTests.swift in Sources */,
+ F0B0E6972AFE6E7E001DC66B /* XCTest+Async.swift in Sources */,
A9A5FA312ACB05160083449F /* MockFileCache.swift in Sources */,
A9A5FA322ACB05160083449F /* RelayCacheTests.swift in Sources */,
A9A5FA332ACB05160083449F /* RelaySelectorTests.swift in Sources */,
@@ -4485,6 +4528,7 @@
F0C6A8432AB08E54000777A8 /* RedeemVoucherViewConfiguration.swift in Sources */,
7AF10EB42ADE85BC00C090B9 /* RelayFilterCoordinator.swift in Sources */,
58FB865526E8BF3100F188BC /* StorePaymentManagerError.swift in Sources */,
+ F09D04B32AE919AC003D4F89 /* OutgoingConnectionProxy.swift in Sources */,
58FD5BF42428C67600112C88 /* InAppPurchaseButton.swift in Sources */,
7AF10EB22ADE859200C090B9 /* AlertViewController.swift in Sources */,
587D9676288989DB00CD8F1C /* NSLayoutConstraint+Helpers.swift in Sources */,
@@ -4502,12 +4546,14 @@
063687BA28EB234F00BE7161 /* PacketTunnelTransport.swift in Sources */,
A9C342C12ACC37E30045F00E /* TunnelStatusBlockObserver.swift in Sources */,
587425C12299833500CA2045 /* RootContainerViewController.swift in Sources */,
+ F09D04BD2AEBB7C5003D4F89 /* OutgoingConnectionService.swift in Sources */,
5896AE84246D5889005B36CB /* CustomDateComponentsFormatting.swift in Sources */,
5871167F2910035700D41AAC /* PreferencesInteractor.swift in Sources */,
7A9CCCC22A96302800DD6A34 /* SafariCoordinator.swift in Sources */,
58E20771274672CA00DE5D77 /* LaunchViewController.swift in Sources */,
F0E8CC032A4C753B007ED3B4 /* WelcomeViewController.swift in Sources */,
584D26C4270C855B004EA533 /* PreferencesDataSource.swift in Sources */,
+ F0D8825B2B04F53600D3EF9A /* OutgoingConnectionData.swift in Sources */,
7A9CCCB62A96302800DD6A34 /* OutOfTimeCoordinator.swift in Sources */,
58FD5BF024238EB300112C88 /* SKProduct+Formatting.swift in Sources */,
58B43C1925F77DB60002C8C3 /* TunnelControlView.swift in Sources */,
diff --git a/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift b/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift
index af0a8f8e47..eb2e8be7b7 100644
--- a/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift
+++ b/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift
@@ -77,6 +77,7 @@ final class ApplicationCoordinator: Coordinator, Presenting, RootContainerViewCo
private let accountsProxy: RESTAccountHandling
private var tunnelObserver: TunnelObserver?
private var appPreferences: AppPreferencesDataSource
+ private var outgoingConnectionService: OutgoingConnectionServiceHandling
private var outOfTimeTimer: Timer?
@@ -91,6 +92,7 @@ final class ApplicationCoordinator: Coordinator, Presenting, RootContainerViewCo
apiProxy: APIQuerying,
devicesProxy: DeviceHandling,
accountsProxy: RESTAccountHandling,
+ outgoingConnectionService: OutgoingConnectionServiceHandling,
appPreferences: AppPreferencesDataSource
) {
self.tunnelManager = tunnelManager
@@ -100,6 +102,7 @@ final class ApplicationCoordinator: Coordinator, Presenting, RootContainerViewCo
self.devicesProxy = devicesProxy
self.accountsProxy = accountsProxy
self.appPreferences = appPreferences
+ self.outgoingConnectionService = outgoingConnectionService
super.init()
@@ -676,7 +679,10 @@ final class ApplicationCoordinator: Coordinator, Presenting, RootContainerViewCo
}
private func makeTunnelCoordinator() -> TunnelCoordinator {
- let tunnelCoordinator = TunnelCoordinator(tunnelManager: tunnelManager)
+ let tunnelCoordinator = TunnelCoordinator(
+ tunnelManager: tunnelManager,
+ outgoingConnectionService: outgoingConnectionService
+ )
tunnelCoordinator.showSelectLocationPicker = { [weak self] in
self?.router.present(.selectLocation, animated: true)
diff --git a/ios/MullvadVPN/Coordinators/TunnelCoordinator.swift b/ios/MullvadVPN/Coordinators/TunnelCoordinator.swift
index 255728ccef..5fa71da17c 100644
--- a/ios/MullvadVPN/Coordinators/TunnelCoordinator.swift
+++ b/ios/MullvadVPN/Coordinators/TunnelCoordinator.swift
@@ -25,10 +25,16 @@ class TunnelCoordinator: Coordinator, Presenting {
var showSelectLocationPicker: (() -> Void)?
- init(tunnelManager: TunnelManager) {
+ init(
+ tunnelManager: TunnelManager,
+ outgoingConnectionService: OutgoingConnectionServiceHandling
+ ) {
self.tunnelManager = tunnelManager
- let interactor = TunnelViewControllerInteractor(tunnelManager: tunnelManager)
+ let interactor = TunnelViewControllerInteractor(
+ tunnelManager: tunnelManager,
+ outgoingConnectionService: outgoingConnectionService
+ )
controller = TunnelViewController(interactor: interactor)
super.init()
diff --git a/ios/MullvadVPN/GeneralAPIs/OutgoingConnectionData.swift b/ios/MullvadVPN/GeneralAPIs/OutgoingConnectionData.swift
new file mode 100644
index 0000000000..9e3aa25cea
--- /dev/null
+++ b/ios/MullvadVPN/GeneralAPIs/OutgoingConnectionData.swift
@@ -0,0 +1,28 @@
+//
+// OutgoingConnectionData.swift
+// MullvadVPN
+//
+// Created by Mojgan on 2023-11-15.
+// Copyright © 2023 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+import Network
+
+typealias IPV4ConnectionData = OutgoingConnectionData<IPv4Address>
+typealias IPV6ConnectionData = OutgoingConnectionData<IPv6Address>
+
+// MARK: - OutgoingConnectionData
+
+struct OutgoingConnectionData<T: Codable & IPAddress>: Codable, Equatable {
+ let ip: T
+ let exitIP: Bool
+
+ enum CodingKeys: String, CodingKey {
+ case ip, exitIP = "mullvad_exit_ip"
+ }
+
+ static func == (lhs: Self, rhs: Self) -> Bool {
+ lhs.ip.rawValue == rhs.ip.rawValue && lhs.exitIP == rhs.exitIP
+ }
+}
diff --git a/ios/MullvadVPN/GeneralAPIs/OutgoingConnectionProxy.swift b/ios/MullvadVPN/GeneralAPIs/OutgoingConnectionProxy.swift
new file mode 100644
index 0000000000..a078efce1a
--- /dev/null
+++ b/ios/MullvadVPN/GeneralAPIs/OutgoingConnectionProxy.swift
@@ -0,0 +1,94 @@
+//
+// OutgoingConnectionProxy.swift
+// MullvadREST
+//
+// Created by Mojgan on 2023-10-24.
+// Copyright © 2023 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+import MullvadREST
+import MullvadTypes
+import Network
+
+protocol OutgoingConnectionHandling {
+ func getIPV6(retryStrategy: REST.RetryStrategy) async throws -> IPV6ConnectionData
+ func getIPV4(retryStrategy: REST.RetryStrategy) async throws -> IPV4ConnectionData
+}
+
+final class OutgoingConnectionProxy: OutgoingConnectionHandling {
+ private enum ExitIPVersion: String {
+ case v4 = "ipv4", v6 = "ipv6"
+
+ var host: String {
+ "\(rawValue).am.i.mullvad.net"
+ }
+ }
+
+ let urlSession: URLSession
+
+ init(urlSession: URLSession) {
+ self.urlSession = urlSession
+ }
+
+ func getIPV6(retryStrategy: REST.RetryStrategy) async throws -> IPV6ConnectionData {
+ try await perform(retryStrategy: retryStrategy, version: .v6)
+ }
+
+ func getIPV4(retryStrategy: REST.RetryStrategy) async throws -> IPV4ConnectionData {
+ try await perform(retryStrategy: retryStrategy, version: .v4)
+ }
+
+ private func perform<T: Decodable>(retryStrategy: REST.RetryStrategy, version: ExitIPVersion) async throws -> T {
+ let delayIterator = retryStrategy.makeDelayIterator()
+ for _ in 0 ..< retryStrategy.maxRetryCount {
+ do {
+ return try await perform(host: version.host)
+ } catch {
+ // ignore if request is cancelled
+ if case URLError.cancelled = error {
+ throw error
+ } else {
+ // retry with the delay
+ guard let delay = delayIterator.next() else { throw error }
+ let mills = UInt64(max(0, delay.milliseconds))
+ let nanos = mills.saturatingMultiplication(1_000_000)
+ try await Task.sleep(nanoseconds: nanos)
+ }
+ }
+ }
+ return try await perform(host: version.host)
+ }
+
+ private func perform<T: Decodable>(host: String) async throws -> T {
+ var urlComponents = URLComponents()
+ urlComponents.scheme = "https"
+ urlComponents.host = host
+ urlComponents.path = "/json"
+
+ guard let url = urlComponents.url else {
+ throw REST.Error.network(URLError(.badURL))
+ }
+ let request = URLRequest(
+ url: url,
+ cachePolicy: .useProtocolCachePolicy,
+ timeoutInterval: REST.defaultAPINetworkTimeout.timeInterval
+ )
+ let (data, response) = try await urlSession.data(for: request)
+ guard let httpResponse = response as? HTTPURLResponse else {
+ throw REST.Error.network(URLError(.badServerResponse))
+ }
+ let decoder = JSONDecoder()
+ guard (200 ..< 300).contains(httpResponse.statusCode) else {
+ throw REST.Error.unhandledResponse(
+ httpResponse.statusCode,
+ try? decoder.decode(
+ REST.ServerErrorResponse.self,
+ from: data
+ )
+ )
+ }
+ let connectionData = try decoder.decode(T.self, from: data)
+ return connectionData
+ }
+}
diff --git a/ios/MullvadVPN/SceneDelegate.swift b/ios/MullvadVPN/SceneDelegate.swift
index 1547ab0f50..d09d147a92 100644
--- a/ios/MullvadVPN/SceneDelegate.swift
+++ b/ios/MullvadVPN/SceneDelegate.swift
@@ -69,6 +69,9 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate, SettingsMigrationUIHand
apiProxy: appDelegate.apiProxy,
devicesProxy: appDelegate.devicesProxy,
accountsProxy: appDelegate.accountsProxy,
+ outgoingConnectionService: OutgoingConnectionService(
+ outgoingConnectionProxy: OutgoingConnectionProxy(urlSession: URLSession(configuration: .ephemeral))
+ ),
appPreferences: AppPreferences()
)
diff --git a/ios/MullvadVPN/UI appearance/UIMetrics.swift b/ios/MullvadVPN/UI appearance/UIMetrics.swift
index 8c23dedde5..74086e46f5 100644
--- a/ios/MullvadVPN/UI appearance/UIMetrics.swift
+++ b/ios/MullvadVPN/UI appearance/UIMetrics.swift
@@ -94,6 +94,11 @@ enum UIMetrics {
static let chipViewLayoutMargins = UIEdgeInsets(top: 3, left: 8, bottom: 3, right: 8)
static let chipViewLabelSpacing: CGFloat = 7
}
+
+ enum ConnectionPanelView {
+ static let inRowHeight: CGFloat = 22
+ static let outRowHeight: CGFloat = 44
+ }
}
extension UIMetrics {
diff --git a/ios/MullvadVPN/View controllers/Tunnel/ConnectionPanelView.swift b/ios/MullvadVPN/View controllers/Tunnel/ConnectionPanelView.swift
index 65cae0c148..31804f74b3 100644
--- a/ios/MullvadVPN/View controllers/Tunnel/ConnectionPanelView.swift
+++ b/ios/MullvadVPN/View controllers/Tunnel/ConnectionPanelView.swift
@@ -75,9 +75,6 @@ class ConnectionPanelView: UIView {
inAddressRow.translatesAutoresizingMaskIntoConstraints = false
outAddressRow.translatesAutoresizingMaskIntoConstraints = false
- // Remove this line when we have out address
- outAddressRow.isHidden = true
-
inAddressRow.title = NSLocalizedString(
"IN_ADDRESS_LABEL",
tableName: "ConnectionPanel",
@@ -105,6 +102,9 @@ class ConnectionPanelView: UIView {
stackView.trailingAnchor.constraint(equalTo: trailingAnchor),
stackView.bottomAnchor.constraint(equalTo: bottomAnchor),
+ inAddressRow.heightAnchor.constraint(equalToConstant: UIMetrics.ConnectionPanelView.inRowHeight),
+ outAddressRow.heightAnchor.constraint(equalToConstant: UIMetrics.ConnectionPanelView.outRowHeight),
+
// Align all text labels with the guide, so that they maintain equal width
textLabelLayoutGuide.trailingAnchor
.constraint(equalTo: inAddressRow.textLabelLayoutGuide.trailingAnchor),
@@ -125,6 +125,7 @@ class ConnectionPanelView: UIView {
private func didChangeDataSource() {
inAddressRow.value = dataSource?.inAddress
outAddressRow.value = dataSource?.outAddress
+ outAddressRow.alpha = dataSource?.outAddress == nil ? 0 : 1.0
}
private func toggleConnectionInfoVisibility() {
@@ -182,6 +183,8 @@ class ConnectionPanelAddressRow: UIView {
detailTextLabel.font = .systemFont(ofSize: 17)
detailTextLabel.textColor = .white
detailTextLabel.translatesAutoresizingMaskIntoConstraints = false
+ detailTextLabel.numberOfLines = .zero
+ detailTextLabel.lineBreakMode = .byWordWrapping
return detailTextLabel
}()
@@ -189,6 +192,7 @@ class ConnectionPanelAddressRow: UIView {
let stackView = UIStackView(arrangedSubviews: [textLabel, detailTextLabel])
stackView.spacing = UIStackView.spacingUseSystem
stackView.translatesAutoresizingMaskIntoConstraints = false
+ stackView.alignment = .top
return stackView
}()
diff --git a/ios/MullvadVPN/View controllers/Tunnel/OutgoingConnectionService.swift b/ios/MullvadVPN/View controllers/Tunnel/OutgoingConnectionService.swift
new file mode 100644
index 0000000000..12ed55c472
--- /dev/null
+++ b/ios/MullvadVPN/View controllers/Tunnel/OutgoingConnectionService.swift
@@ -0,0 +1,44 @@
+//
+// OutgoingConnectionService.swift
+// MullvadVPN
+//
+// Created by Mojgan on 2023-10-27.
+// Copyright © 2023 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+import MullvadLogging
+import Network
+
+protocol OutgoingConnectionServiceHandling {
+ func getOutgoingConnectionInfo() async throws -> OutgoingConnectionInfo
+}
+
+final class OutgoingConnectionService: OutgoingConnectionServiceHandling {
+ private let outgoingConnectionProxy: OutgoingConnectionHandling
+
+ init(outgoingConnectionProxy: OutgoingConnectionHandling) {
+ self.outgoingConnectionProxy = outgoingConnectionProxy
+ }
+
+ func getOutgoingConnectionInfo() async throws -> OutgoingConnectionInfo {
+ let ipv4ConnectionInfo = try await outgoingConnectionProxy.getIPV4(retryStrategy: .default)
+ let ipv6ConnectionInfo = try await outgoingConnectionProxy.getIPV6(retryStrategy: .noRetry)
+ return OutgoingConnectionInfo(ipv4: ipv4ConnectionInfo, ipv6: ipv6ConnectionInfo)
+ }
+}
+
+struct OutgoingConnectionInfo {
+ /// IPv4 exit connection.
+ let ipv4: IPV4ConnectionData
+
+ /// IPv6 exit connection.
+ let ipv6: IPV6ConnectionData
+
+ var outAddress: String? {
+ let v4 = ipv4.exitIP ? "\(ipv4.ip)" : nil
+ let v6 = ipv6.exitIP ? "\(ipv6.ip)" : nil
+ let outAddress = [v4, v6].compactMap { $0 }.joined(separator: "\n")
+ return outAddress.isEmpty ? nil : outAddress
+ }
+}
diff --git a/ios/MullvadVPN/View controllers/Tunnel/TunnelControlView.swift b/ios/MullvadVPN/View controllers/Tunnel/TunnelControlView.swift
index 4abc59791c..132a972911 100644
--- a/ios/MullvadVPN/View controllers/Tunnel/TunnelControlView.swift
+++ b/ios/MullvadVPN/View controllers/Tunnel/TunnelControlView.swift
@@ -147,6 +147,15 @@ final class TunnelControlView: UIView {
updateTunnelRelay()
}
+ func update(from outgoingConnectionInfo: OutgoingConnectionInfo) {
+ if let tunnelRelay = tunnelState.relay {
+ connectionPanel.dataSource = ConnectionPanelData(
+ inAddress: "\(tunnelRelay.endpoint.ipv4Relay) UDP",
+ outAddress: outgoingConnectionInfo.outAddress
+ )
+ }
+ }
+
func setAnimatingActivity(_ isAnimating: Bool) {
if isAnimating {
activityIndicator.startAnimating()
@@ -223,6 +232,7 @@ final class TunnelControlView: UIView {
)
connectionPanel.dataSource = ConnectionPanelData(
+ // TODO: - UDP shouldn't be hardcoded after tunnel obfuscation
inAddress: "\(tunnelRelay.endpoint.ipv4Relay) UDP",
outAddress: nil
)
@@ -261,8 +271,7 @@ final class TunnelControlView: UIView {
containerView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor),
containerView.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor),
- locationContainerView.topAnchor
- .constraint(greaterThanOrEqualTo: containerView.topAnchor),
+ locationContainerView.topAnchor.constraint(greaterThanOrEqualTo: containerView.topAnchor),
locationContainerView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
locationContainerView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
diff --git a/ios/MullvadVPN/View controllers/Tunnel/TunnelViewController.swift b/ios/MullvadVPN/View controllers/Tunnel/TunnelViewController.swift
index 10095a0f3a..3c6cc11ea6 100644
--- a/ios/MullvadVPN/View controllers/Tunnel/TunnelViewController.swift
+++ b/ios/MullvadVPN/View controllers/Tunnel/TunnelViewController.swift
@@ -63,6 +63,10 @@ class TunnelViewController: UIViewController, RootContainment {
self?.setTunnelState(tunnelStatus.state, animated: true)
}
+ interactor.didGetOutGoingAddress = { [weak self] outgoingConnectionInfo in
+ self?.contentView.update(from: outgoingConnectionInfo)
+ }
+
contentView.actionHandler = { [weak self] action in
switch action {
case .connect:
@@ -143,7 +147,6 @@ class TunnelViewController: UIViewController, RootContainment {
case let .connected(tunnelRelay):
let center = tunnelRelay.location.geoCoordinate
-
mapViewController.setCenter(center, animated: animated) {
self.contentView.setAnimatingActivity(false)
self.mapViewController.addLocationMarker(coordinate: center)
diff --git a/ios/MullvadVPN/View controllers/Tunnel/TunnelViewControllerInteractor.swift b/ios/MullvadVPN/View controllers/Tunnel/TunnelViewControllerInteractor.swift
index 47adb52703..47b75fd7d5 100644
--- a/ios/MullvadVPN/View controllers/Tunnel/TunnelViewControllerInteractor.swift
+++ b/ios/MullvadVPN/View controllers/Tunnel/TunnelViewControllerInteractor.swift
@@ -8,13 +8,17 @@
import Foundation
import MullvadSettings
+import MullvadTypes
final class TunnelViewControllerInteractor {
private let tunnelManager: TunnelManager
+ private let outgoingConnectionService: OutgoingConnectionServiceHandling
private var tunnelObserver: TunnelObserver?
+ private var outgoingConnectionTask: Task<Void, Error>?
- var didUpdateDeviceState: ((_ deviceState: DeviceState, _ previousDeviceState: DeviceState) -> Void)?
var didUpdateTunnelStatus: ((TunnelStatus) -> Void)?
+ var didUpdateDeviceState: ((_ deviceState: DeviceState, _ previousDeviceState: DeviceState) -> Void)?
+ var didGetOutGoingAddress: (@MainActor (OutgoingConnectionInfo) -> Void)?
var tunnelStatus: TunnelStatus {
tunnelManager.tunnelStatus
@@ -24,17 +28,37 @@ final class TunnelViewControllerInteractor {
tunnelManager.deviceState
}
- init(tunnelManager: TunnelManager) {
+ deinit {
+ outgoingConnectionTask?.cancel()
+ }
+
+ init(
+ tunnelManager: TunnelManager,
+ outgoingConnectionService: OutgoingConnectionServiceHandling
+ ) {
self.tunnelManager = tunnelManager
+ self.outgoingConnectionService = outgoingConnectionService
let tunnelObserver = TunnelBlockObserver(
didUpdateTunnelStatus: { [weak self] _, tunnelStatus in
- self?.didUpdateTunnelStatus?(tunnelStatus)
+ guard let self else { return }
+ outgoingConnectionTask?.cancel()
+ didUpdateTunnelStatus?(tunnelStatus)
+ if case .connected = tunnelStatus.state {
+ outgoingConnectionTask = Task(priority: .high) { [weak self] in
+ guard let outgoingConnectionInfo = try await self?.outgoingConnectionService
+ .getOutgoingConnectionInfo() else {
+ return
+ }
+ await self?.didGetOutGoingAddress?(outgoingConnectionInfo)
+ }
+ }
},
didUpdateDeviceState: { [weak self] _, deviceState, previousDeviceState in
self?.didUpdateDeviceState?(deviceState, previousDeviceState)
}
)
+
tunnelManager.addObserver(tunnelObserver)
self.tunnelObserver = tunnelObserver
diff --git a/ios/MullvadVPNTests/MockURLProtocol.swift b/ios/MullvadVPNTests/MockURLProtocol.swift
new file mode 100644
index 0000000000..061a132528
--- /dev/null
+++ b/ios/MullvadVPNTests/MockURLProtocol.swift
@@ -0,0 +1,55 @@
+//
+// MockURLProtocol.swift
+// MullvadVPNTests
+//
+// Created by Mojgan on 2023-10-25.
+// Copyright © 2023 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+
+class MockURLProtocol: URLProtocol {
+ static var error: Error?
+ static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
+
+ override class func canInit(with request: URLRequest) -> Bool {
+ return true
+ }
+
+ override class func canonicalRequest(for request: URLRequest) -> URLRequest {
+ return request
+ }
+
+ override func startLoading() {
+ if let error = MockURLProtocol.error {
+ client?.urlProtocol(self, didFailWithError: error)
+ return
+ }
+
+ guard let handler = MockURLProtocol.requestHandler else {
+ assertionFailure("Received unexpected request with no handler set")
+ return
+ }
+
+ do {
+ let (response, data) = try handler(request)
+ client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
+ client?.urlProtocol(self, didLoad: data)
+ client?.urlProtocolDidFinishLoading(self)
+ } catch {
+ client?.urlProtocol(self, didFailWithError: error)
+ }
+ }
+
+ override func stopLoading() {
+ // stop loading here
+ }
+}
+
+extension URLSession {
+ static let mock = {
+ let configuration = URLSessionConfiguration.ephemeral
+ configuration.protocolClasses = [MockURLProtocol.self]
+ return URLSession(configuration: configuration)
+ }()
+}
diff --git a/ios/MullvadVPNTests/OutgoingConnectionProxy+Stub.swift b/ios/MullvadVPNTests/OutgoingConnectionProxy+Stub.swift
new file mode 100644
index 0000000000..b1eaef38fa
--- /dev/null
+++ b/ios/MullvadVPNTests/OutgoingConnectionProxy+Stub.swift
@@ -0,0 +1,39 @@
+//
+// File.swift
+// MullvadVPNTests
+//
+// Created by Mojgan on 2023-10-25.
+// Copyright © 2023 Mullvad VPN AB. All rights reserved.
+//
+
+import MullvadREST
+
+struct OutgoingConnectionProxyStub: OutgoingConnectionHandling {
+ var ipV4: IPV4ConnectionData
+ var ipV6: IPV6ConnectionData
+ var error: Error?
+
+ func getIPV6(retryStrategy: MullvadREST.REST.RetryStrategy) async throws -> IPV6ConnectionData {
+ if let error {
+ throw error
+ } else {
+ return ipV6
+ }
+ }
+
+ func getIPV4(retryStrategy: MullvadREST.REST.RetryStrategy) async throws -> IPV4ConnectionData {
+ if let error {
+ throw error
+ } else {
+ return ipV4
+ }
+ }
+}
+
+extension IPV4ConnectionData {
+ static let mock = IPV4ConnectionData(ip: .loopback, exitIP: true)
+}
+
+extension IPV6ConnectionData {
+ static let mock = IPV6ConnectionData(ip: .loopback, exitIP: true)
+}
diff --git a/ios/MullvadVPNTests/OutgoingConnectionProxyTests.swift b/ios/MullvadVPNTests/OutgoingConnectionProxyTests.swift
new file mode 100644
index 0000000000..afa8ada0fa
--- /dev/null
+++ b/ios/MullvadVPNTests/OutgoingConnectionProxyTests.swift
@@ -0,0 +1,127 @@
+//
+// OutgoingConnectionProxyTests.swift
+// MullvadVPNTests
+//
+// Created by Mojgan on 2023-10-25.
+// Copyright © 2023 Mullvad VPN AB. All rights reserved.
+//
+import MullvadREST
+import XCTest
+
+final class OutgoingConnectionProxyTests: XCTestCase {
+ private var outgoingConnectionProxy: OutgoingConnectionProxy!
+ private var mockIPV6ConnectionData: Data!
+ private var mockIPV4ConnectionData: Data!
+
+ private let encoder = JSONEncoder()
+
+ override func setUpWithError() throws {
+ outgoingConnectionProxy = OutgoingConnectionProxy(urlSession: .mock)
+ mockIPV4ConnectionData = try encoder.encode(IPV4ConnectionData.mock)
+ mockIPV6ConnectionData = try encoder.encode(IPV6ConnectionData.mock)
+ }
+
+ override func tearDownWithError() throws {
+ outgoingConnectionProxy = nil
+ mockIPV4ConnectionData.removeAll()
+ mockIPV6ConnectionData.removeAll()
+ }
+
+ func testNoInternetConnection() async throws {
+ let noIPv4Expectation = expectation(description: "Did not receive IPv4")
+ let error = URLError(URLError.notConnectedToInternet)
+
+ MockURLProtocol.error = error
+ MockURLProtocol.requestHandler = nil
+
+ await XCTAssertThrowsErrorAsync(try await outgoingConnectionProxy.getIPV4(retryStrategy: .noRetry)) { error in
+ noIPv4Expectation.fulfill()
+ XCTAssertEqual((error as? URLError)?.code, .notConnectedToInternet)
+ }
+ await fulfillment(of: [noIPv4Expectation], timeout: 1)
+ }
+
+ func testSuccessGettingIPV4() async throws {
+ let iPv4Expectation = expectation(description: "Did receive IPv4")
+
+ MockURLProtocol.error = nil
+ MockURLProtocol.requestHandler = { _ in
+ let response = HTTPURLResponse(
+ url: URL(string: "https://ipv4.am.i.mullvad.net/json")!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: ["Content-Type": "application/json"]
+ )!
+ return (response, self.mockIPV4ConnectionData)
+ }
+
+ let result = try await outgoingConnectionProxy.getIPV4(retryStrategy: .noRetry)
+
+ if result.ip == IPV4ConnectionData.mock.ip {
+ iPv4Expectation.fulfill()
+ }
+ await fulfillment(of: [iPv4Expectation], timeout: 1)
+ }
+
+ func testFailureGettingIPV4() async throws {
+ let noIPv4Expectation = expectation(description: "Did not receive IPv4")
+
+ MockURLProtocol.error = nil
+ MockURLProtocol.requestHandler = { _ in
+ let response = HTTPURLResponse(
+ url: URL(string: "https://ipv4.am.i.mullvad.net/json")!,
+ statusCode: 503,
+ httpVersion: nil,
+ headerFields: ["Content-Type": "application/json"]
+ )!
+ return (response, Data())
+ }
+
+ await XCTAssertThrowsErrorAsync(try await outgoingConnectionProxy.getIPV4(retryStrategy: .noRetry)) { _ in
+ noIPv4Expectation.fulfill()
+ }
+ await fulfillment(of: [noIPv4Expectation], timeout: 1)
+ }
+
+ func testSuccessGettingIPV6() async throws {
+ let ipv6Expectation = expectation(description: "Did receive IPv6")
+
+ MockURLProtocol.error = nil
+ MockURLProtocol.requestHandler = { _ in
+ let response = HTTPURLResponse(
+ url: URL(string: "https://ipv6.am.i.mullvad.net/json")!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: ["Content-Type": "application/json"]
+ )!
+ return (response, self.mockIPV6ConnectionData)
+ }
+
+ let result = try await outgoingConnectionProxy.getIPV6(retryStrategy: .noRetry)
+
+ if result.ip == IPV6ConnectionData.mock.ip {
+ ipv6Expectation.fulfill()
+ }
+ await fulfillment(of: [ipv6Expectation], timeout: 1.0)
+ }
+
+ func testFailureGettingIPV6() async throws {
+ let noIPv6Expectation = expectation(description: "Did not receive IPv6")
+
+ MockURLProtocol.error = nil
+ MockURLProtocol.requestHandler = { _ in
+ let response = HTTPURLResponse(
+ url: URL(string: "https://ipv6.am.i.mullvad.net/json")!,
+ statusCode: 404,
+ httpVersion: nil,
+ headerFields: ["Content-Type": "application/json"]
+ )!
+ return (response, Data())
+ }
+
+ await XCTAssertThrowsErrorAsync(try await outgoingConnectionProxy.getIPV6(retryStrategy: .noRetry)) { _ in
+ noIPv6Expectation.fulfill()
+ }
+ await fulfillment(of: [noIPv6Expectation], timeout: 1)
+ }
+}
diff --git a/ios/MullvadVPNTests/OutgoingConnectionServiceTests.swift b/ios/MullvadVPNTests/OutgoingConnectionServiceTests.swift
new file mode 100644
index 0000000000..a98cf3e20f
--- /dev/null
+++ b/ios/MullvadVPNTests/OutgoingConnectionServiceTests.swift
@@ -0,0 +1,49 @@
+//
+// OutgoingConnectionServiceTests.swift
+// MullvadVPNTests
+//
+// Created by Mojgan on 2023-11-02.
+// Copyright © 2023 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+import XCTest
+
+final class OutgoingConnectionServiceTests: XCTestCase {
+ func testSuccessGetOutgoingConnectionInfo() async throws {
+ let mockOutgoingConnectionProxy = OutgoingConnectionProxyStub(
+ ipV4: .mock,
+ ipV6: .mock,
+ error: nil
+ )
+ let outgoingConnectionService = OutgoingConnectionService(outgoingConnectionProxy: mockOutgoingConnectionProxy)
+ let successExpectation = expectation(description: "Did receive exit IPs")
+ let result = try await outgoingConnectionService.getOutgoingConnectionInfo()
+ if result.ipv4 == .mock,
+ result.ipv6 == .mock {
+ successExpectation.fulfill()
+ }
+ await fulfillment(of: [successExpectation], timeout: 1.0)
+ }
+
+ func testFailureGetOutgoingConnectionInfo() async throws {
+ let mockOutgoingConnectionProxy = OutgoingConnectionProxyStub(
+ ipV4: .mock,
+ ipV6: .mock,
+ error: NetworkErrorStub.somethingWentWrong
+ )
+ let outgoingConnectionService = OutgoingConnectionService(outgoingConnectionProxy: mockOutgoingConnectionProxy)
+
+ let failExpectation = expectation(description: "Did not receive exit IPs")
+ do {
+ _ = try await outgoingConnectionService.getOutgoingConnectionInfo()
+ } catch {
+ failExpectation.fulfill()
+ }
+ await fulfillment(of: [failExpectation], timeout: 1.0)
+ }
+}
+
+enum NetworkErrorStub: Error {
+ case somethingWentWrong
+}
diff --git a/ios/MullvadVPNTests/XCTest+Async.swift b/ios/MullvadVPNTests/XCTest+Async.swift
new file mode 100644
index 0000000000..0db26f9458
--- /dev/null
+++ b/ios/MullvadVPNTests/XCTest+Async.swift
@@ -0,0 +1,27 @@
+//
+// XCTest+Async.swift
+// MullvadVPNTests
+//
+// Created by Mojgan on 2023-11-10.
+// Copyright © 2023 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+import XCTest
+
+extension XCTest {
+ func XCTAssertThrowsErrorAsync<T: Sendable>(
+ _ expression: @autoclosure () async throws -> T,
+ _ message: @autoclosure () -> String = "",
+ file: StaticString = #filePath,
+ line: UInt = #line,
+ _ errorHandler: (_ error: Error) -> Void = { _ in }
+ ) async {
+ do {
+ _ = try await expression()
+ XCTFail(message(), file: file, line: line)
+ } catch {
+ errorHandler(error)
+ }
+ }
+}