From e894e072064eadd27ea74c3bffbbd94e7003a1c1 Mon Sep 17 00:00:00 2001 From: Mojgan Date: Fri, 3 Nov 2023 11:50:47 +0100 Subject: [PATCH] show outgoing connection address on map view --- ios/MullvadVPN.xcodeproj/project.pbxproj | 44 ++++- .../Coordinators/ApplicationCoordinator.swift | 8 +- .../Coordinators/TunnelCoordinator.swift | 10 +- .../GeneralAPIs/OutgoingConnectionProxy.swift | 127 +++++++++++++ ios/MullvadVPN/SceneDelegate.swift | 4 +- ios/MullvadVPN/UI appearance/UIMetrics.swift | 5 + .../Tunnel/ConnectionPanelView.swift | 10 +- .../Tunnel/OutgoingConnectionService.swift | 78 ++++++++ .../Tunnel/TunnelControlView.swift | 12 +- .../Tunnel/TunnelViewController.swift | 5 +- .../TunnelViewControllerInteractor.swift | 27 ++- ios/MullvadVPNTests/MockURLProtocol.swift | 55 ++++++ .../OutgoingConnectionProxy+Stub.swift | 42 ++++ .../OutgoingConnectionProxyTests.swift | 179 ++++++++++++++++++ .../OutgoingConnectionServiceTests.swift | 56 ++++++ 15 files changed, 645 insertions(+), 17 deletions(-) create mode 100644 ios/MullvadVPN/GeneralAPIs/OutgoingConnectionProxy.swift create mode 100644 ios/MullvadVPN/View controllers/Tunnel/OutgoingConnectionService.swift create mode 100644 ios/MullvadVPNTests/MockURLProtocol.swift create mode 100644 ios/MullvadVPNTests/OutgoingConnectionProxy+Stub.swift create mode 100644 ios/MullvadVPNTests/OutgoingConnectionProxyTests.swift create mode 100644 ios/MullvadVPNTests/OutgoingConnectionServiceTests.swift diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index 61a08e84e5b6..ff06e97de479 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -648,6 +648,14 @@ 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 */; }; 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 */; }; @@ -1632,6 +1640,12 @@ F09A29792A9F8A9B00EA3B6F /* VoucherTextField.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VoucherTextField.swift; sourceTree = ""; }; F09A297A2A9F8A9B00EA3B6F /* RedeemVoucherContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RedeemVoucherContentView.swift; sourceTree = ""; }; F09A297F2A9F8AD200EA3B6F /* RedeemVoucherInteractor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RedeemVoucherInteractor.swift; sourceTree = ""; }; + F09D04AF2AE7F83D003D4F89 /* OutgoingConnectionProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutgoingConnectionProxy.swift; sourceTree = ""; }; + F09D04B42AE93CB6003D4F89 /* OutgoingConnectionProxy+Stub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OutgoingConnectionProxy+Stub.swift"; sourceTree = ""; }; + F09D04B62AE941DA003D4F89 /* OutgoingConnectionProxyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutgoingConnectionProxyTests.swift; sourceTree = ""; }; + F09D04BA2AE95396003D4F89 /* MockURLProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockURLProtocol.swift; sourceTree = ""; }; + F09D04BC2AEBB7C5003D4F89 /* OutgoingConnectionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutgoingConnectionService.swift; sourceTree = ""; }; + F09D04BF2AF39D63003D4F89 /* OutgoingConnectionServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutgoingConnectionServiceTests.swift; sourceTree = ""; }; F0C2AEFC2A0BB5CC00986207 /* NotificationProviderIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationProviderIdentifier.swift; sourceTree = ""; }; F0C6A8422AB08E54000777A8 /* RedeemVoucherViewConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedeemVoucherViewConfiguration.swift; sourceTree = ""; }; F0C6FA802A66E23300F521F0 /* DeleteAccountOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteAccountOperation.swift; sourceTree = ""; }; @@ -2124,14 +2138,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 = ""; @@ -2487,6 +2502,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 */, @@ -2731,6 +2750,7 @@ 58C774C929AB543C003A1A56 /* Containers */, 58CAF9F22983D32200BE19F7 /* Coordinators */, 583FE02329C1AC9F006E85F9 /* Extensions */, + F09D04B82AE94F27003D4F89 /* GeneralAPIs */, 58B26E1F2943516500D5980C /* Notifications */, 586A950B2901250A007BAF2B /* Operations */, 5864859729A0D012006C5743 /* Presentation controllers */, @@ -3012,6 +3032,14 @@ path = RedeemVoucher; sourceTree = ""; }; + F09D04B82AE94F27003D4F89 /* GeneralAPIs */ = { + isa = PBXGroup; + children = ( + F09D04AF2AE7F83D003D4F89 /* OutgoingConnectionProxy.swift */, + ); + path = GeneralAPIs; + sourceTree = ""; + }; F0E361892A4ADCF500AEEF2B /* Welcome */ = { isa = PBXGroup; children = ( @@ -4094,11 +4122,14 @@ 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 */, A9A5F9F42ACB05160083449F /* AccountExpiryInAppNotificationProvider.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 */, @@ -4138,12 +4169,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 */, @@ -4152,6 +4185,7 @@ A9C342C32ACC3EE90045F00E /* RelayCacheTracker+Stubs.swift in Sources */, A9A5FA222ACB05160083449F /* TunnelObserver.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 */, @@ -4477,6 +4511,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 */, @@ -4494,6 +4529,7 @@ 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 */, diff --git a/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift b/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift index f5fce3f250f1..86bdb8925e77 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 255728ccef1b..5fa71da17c72 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/OutgoingConnectionProxy.swift b/ios/MullvadVPN/GeneralAPIs/OutgoingConnectionProxy.swift new file mode 100644 index 000000000000..a21bf66abe9f --- /dev/null +++ b/ios/MullvadVPN/GeneralAPIs/OutgoingConnectionProxy.swift @@ -0,0 +1,127 @@ +// +// 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 -> OutgoingConnectionProxy.IPV6ConnectionData + func getIPV4(retryStrategy: REST.RetryStrategy) async throws -> OutgoingConnectionProxy.IPV4ConnectionData +} + +final class OutgoingConnectionProxy: OutgoingConnectionHandling { + let urlSession: URLSession + + private lazy var jsonDecoder: JSONDecoder = { + let decoder = JSONDecoder() + return decoder + }() + + init(urlSession: URLSession) { + self.urlSession = urlSession + } + + func getIPV6(retryStrategy: REST.RetryStrategy) async throws -> IPV6ConnectionData { + try await perform(retryStrategy: retryStrategy, path: "https://ipv6.am.i.mullvad.net/json") + } + + func getIPV4(retryStrategy: REST.RetryStrategy) async throws -> IPV4ConnectionData { + try await perform(retryStrategy: retryStrategy, path: "https://ipv4.am.i.mullvad.net/json") + } + + private func perform(retryStrategy: REST.RetryStrategy, path: String) async throws -> T { + let delayIterator = retryStrategy.makeDelayIterator() + for _ in 0 ..< retryStrategy.maxRetryCount { + do { + return try await perform(path: path) + } 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(path: path) + } + + private func perform(path: String) async throws -> T { + guard let url = URL(string: path) else { + throw REST.Error.network(URLError(.badURL)) + } + do { + let (data, response) = try await urlSession.data(from: url) + guard let httpResponse = response as? HTTPURLResponse else { + throw REST.Error.network(URLError(.badServerResponse)) + } + + guard (200 ..< 300).contains(httpResponse.statusCode) else { + throw REST.Error.unhandledResponse( + httpResponse.statusCode, + try? jsonDecoder.decode( + REST.ServerErrorResponse.self, + from: data + ) + ) + } + let connectionData = try jsonDecoder.decode(T.self, from: data) + return connectionData + + } catch { + throw error + } + } +} + +extension OutgoingConnectionProxy { + typealias IPV4ConnectionData = OutgoingConnectionData + typealias IPV6ConnectionData = OutgoingConnectionData + typealias IPAddressType = Codable & IPAddress + + // MARK: - OutgoingConnectionData + + struct OutgoingConnectionData: Codable, Equatable { + let ip: T? + let exitIP: Bool + + enum CodingKeys: String, CodingKey { + case ip, exitIP = "mullvad_exit_ip" + } + + init(ip: T, exitIP: Bool) { + self.ip = ip + self.exitIP = exitIP + } + + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + self.ip = try values.decodeIfPresent(T.self, forKey: .ip) + self.exitIP = try values.decodeIfPresent(Bool.self, forKey: .exitIP) ?? false + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: Self.CodingKeys.self) + try container.encode(self.ip, forKey: Self.CodingKeys.ip) + try container.encode(self.exitIP, forKey: Self.CodingKeys.exitIP) + } + + static func == ( + lhs: OutgoingConnectionProxy.OutgoingConnectionData, + rhs: OutgoingConnectionProxy.OutgoingConnectionData + ) -> Bool { + lhs.ip?.rawValue == rhs.ip?.rawValue + } + } +} diff --git a/ios/MullvadVPN/SceneDelegate.swift b/ios/MullvadVPN/SceneDelegate.swift index 1547ab0f50f1..2e9f5eb02779 100644 --- a/ios/MullvadVPN/SceneDelegate.swift +++ b/ios/MullvadVPN/SceneDelegate.swift @@ -69,7 +69,9 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate, SettingsMigrationUIHand apiProxy: appDelegate.apiProxy, devicesProxy: appDelegate.devicesProxy, accountsProxy: appDelegate.accountsProxy, - appPreferences: AppPreferences() + outgoingConnectionService: OutgoingConnectionService( + outgoingConnectionProxy: OutgoingConnectionProxy(urlSession: .shared) + ), appPreferences: AppPreferences() ) appCoordinator?.onShowSettings = { [weak self] in diff --git a/ios/MullvadVPN/UI appearance/UIMetrics.swift b/ios/MullvadVPN/UI appearance/UIMetrics.swift index 8c23dedde59e..74086e46f569 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 65cae0c148a5..31804f74b343 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 000000000000..3c20b9618df3 --- /dev/null +++ b/ios/MullvadVPN/View controllers/Tunnel/OutgoingConnectionService.swift @@ -0,0 +1,78 @@ +// +// 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 { + enum OutgoingConnectionResult { + case ipV4(OutgoingConnectionProxy.IPV4ConnectionData) + case ipV6(OutgoingConnectionProxy.IPV6ConnectionData?) + } + + private let outgoingConnectionProxy: OutgoingConnectionHandling + + init(outgoingConnectionProxy: OutgoingConnectionHandling) { + self.outgoingConnectionProxy = outgoingConnectionProxy + } + + func getOutgoingConnectionInfo() async throws -> OutgoingConnectionInfo { + try await withThrowingTaskGroup(of: OutgoingConnectionResult.self) { taskGroup -> OutgoingConnectionInfo in + + taskGroup.addTask(priority: .userInitiated) { + let ipv4ConnectionInfo = try await self.outgoingConnectionProxy.getIPV4(retryStrategy: .default) + return .ipV4(ipv4ConnectionInfo) + } + + taskGroup.addTask(priority: .userInitiated) { + let ipv6ConnectionInfo = try? await self.outgoingConnectionProxy.getIPV6(retryStrategy: .noRetry) + return .ipV6(ipv6ConnectionInfo) + } + + var ipv4 = OutgoingConnectionProxy.IPV4ConnectionData(ip: .any, exitIP: false) + var ipv6: OutgoingConnectionProxy.IPV6ConnectionData? + + for try await value in taskGroup { + switch value { + case let .ipV4(connectionInfo): + ipv4 = connectionInfo + case let .ipV6(connectionInfo): + ipv6 = connectionInfo + } + } + + return OutgoingConnectionInfo(ipv4: ipv4, ipv6: ipv6) + } + } +} + +struct OutgoingConnectionInfo { + /// IPv4 exit connection. + let ipv4: OutgoingConnectionProxy.IPV4ConnectionData + + /// IPv6 exit connection. + let ipv6: OutgoingConnectionProxy.IPV6ConnectionData? + + var outAddress: String? { + let v4 = ipv4.exitIP ? ipv4.ip : nil + let v6 = ipv6.flatMap { $0 }.map { $0.exitIP ? $0.ip : nil } + var string = "" + if let v4 { + string += v4.debugDescription + "\n" + } + if let v6 { + string += v6?.debugDescription ?? "" + } + return string.isEmpty ? nil : string + } +} diff --git a/ios/MullvadVPN/View controllers/Tunnel/TunnelControlView.swift b/ios/MullvadVPN/View controllers/Tunnel/TunnelControlView.swift index 4abc59791cd4..fd08b010f9fc 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() @@ -261,8 +270,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 10095a0f3a3d..3c6cc11ea603 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 47adb52703c5..7896f48512cf 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? - 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,34 @@ final class TunnelViewControllerInteractor { tunnelManager.deviceState } - init(tunnelManager: TunnelManager) { + 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() + switch tunnelStatus.state { + case .connected: + outgoingConnectionTask = Task { + let outgoingConnectionInfo = try await self.outgoingConnectionService + .getOutgoingConnectionInfo() + await self.didGetOutGoingAddress?(outgoingConnectionInfo) + } + fallthrough + default: + didUpdateTunnelStatus?(tunnelStatus) + } }, 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 000000000000..061a132528f2 --- /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 000000000000..cbe761c8d2df --- /dev/null +++ b/ios/MullvadVPNTests/OutgoingConnectionProxy+Stub.swift @@ -0,0 +1,42 @@ +// +// File.swift +// MullvadVPNTests +// +// Created by Mojgan on 2023-10-25. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import MullvadREST + +struct OutgoingConnectionProxyStub: OutgoingConnectionHandling { + static var hasError = false + var ipV4: OutgoingConnectionProxy.IPV4ConnectionData + var ipV6: OutgoingConnectionProxy.IPV6ConnectionData + var error: Error + + func getIPV6(retryStrategy: MullvadREST.REST.RetryStrategy) async throws -> OutgoingConnectionProxy + .IPV6ConnectionData { + if Self.hasError { + throw error + } else { + return ipV6 + } + } + + func getIPV4(retryStrategy: MullvadREST.REST.RetryStrategy) async throws -> OutgoingConnectionProxy + .IPV4ConnectionData { + if Self.hasError { + throw error + } else { + return ipV4 + } + } +} + +extension OutgoingConnectionProxy.IPV4ConnectionData { + static let mock = OutgoingConnectionProxy.IPV4ConnectionData(ip: .loopback, exitIP: true) +} + +extension OutgoingConnectionProxy.IPV6ConnectionData { + static let mock = OutgoingConnectionProxy.IPV6ConnectionData(ip: .loopback, exitIP: true) +} diff --git a/ios/MullvadVPNTests/OutgoingConnectionProxyTests.swift b/ios/MullvadVPNTests/OutgoingConnectionProxyTests.swift new file mode 100644 index 000000000000..61bc0da50c8e --- /dev/null +++ b/ios/MullvadVPNTests/OutgoingConnectionProxyTests.swift @@ -0,0 +1,179 @@ +// +// 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 = REST.Coding.makeJSONEncoder() + + override func setUp() { + outgoingConnectionProxy = OutgoingConnectionProxy(urlSession: URLSession.mock) + // swiftlint:disable force_try + mockIPV4ConnectionData = try! encoder.encode(OutgoingConnectionProxy.IPV4ConnectionData.mock) + mockIPV6ConnectionData = try! encoder.encode(OutgoingConnectionProxy.IPV6ConnectionData.mock) + // swiftlint:enable force_try + } + + override func tearDown() { + outgoingConnectionProxy = nil + mockIPV4ConnectionData.removeAll() + mockIPV6ConnectionData.removeAll() + } + + func testNoInternetConnection() async throws { + let failureExpectation = expectation(description: "should fail") + let successExpectation = expectation(description: "should not succeed") + successExpectation.isInverted = true + + var thrownError: Error? + let errorHandler = { thrownError = $0 } + let error = URLError(URLError.notConnectedToInternet) + + MockURLProtocol.error = error + MockURLProtocol.requestHandler = nil + + do { + _ = try await outgoingConnectionProxy.getIPV4(retryStrategy: .noRetry) + successExpectation.fulfill() + } catch { + errorHandler(error) + failureExpectation.fulfill() + } + + await fulfillment(of: [successExpectation, failureExpectation], timeout: 1.0) + XCTAssertEqual(error.localizedDescription, thrownError?.localizedDescription) + } + + func testSuccessGettingIPV4() async throws { + let successExpectation = expectation(description: "should succeed") + let failureExpectation = expectation(description: "should not fail") + failureExpectation.isInverted = true + + 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 == OutgoingConnectionProxy.IPV4ConnectionData.mock.ip { + successExpectation.fulfill() + } else { + failureExpectation.fulfill() + } + await fulfillment(of: [successExpectation, failureExpectation], timeout: 1.0) + } + + func testFailureGettingIPV4() async throws { + let failureExpectation = expectation(description: "should fail") + let successExpectation = expectation(description: "should not succeed") + successExpectation.isInverted = true + + var thrownError: Error? + let errorHandler = { thrownError = $0 } + + 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()) + } + + do { + _ = try await outgoingConnectionProxy.getIPV4(retryStrategy: .default) + successExpectation.fulfill() + } catch { + errorHandler(error) + failureExpectation.fulfill() + } + + await fulfillment(of: [successExpectation, failureExpectation], timeout: 1.0) + if let restError = thrownError as? REST.Error, + case let REST.Error.unhandledResponse(statusCode, _) = restError { + XCTAssertEqual(503, statusCode) + } else { + XCTFail("Unexpected Error!") + } + } + + func testSuccessGettingIPV6() async throws { + let successExpectation = expectation(description: "should succeed") + let failureExpectation = expectation(description: "should not fail") + failureExpectation.isInverted = true + + 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 == OutgoingConnectionProxy.IPV6ConnectionData.mock.ip { + successExpectation.fulfill() + } else { + failureExpectation.fulfill() + } + await fulfillment(of: [successExpectation, failureExpectation], timeout: 1.0) + } + + func testFailureGettingIPV6() async throws { + let failureExpectation = expectation(description: "should fail") + let successExpectation = expectation(description: "should not succeed") + successExpectation.isInverted = true + + var thrownError: Error? + let errorHandler = { thrownError = $0 } + + 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()) + } + + do { + _ = try await outgoingConnectionProxy.getIPV6(retryStrategy: .noRetry) + successExpectation.fulfill() + } catch { + errorHandler(error) + failureExpectation.fulfill() + } + + await fulfillment(of: [successExpectation, failureExpectation], timeout: 1.0) + if let restError = thrownError as? REST.Error, + case let REST.Error.unhandledResponse(statusCode, _) = restError { + XCTAssertEqual(404, statusCode) + } else { + XCTFail("Unexpected Error!") + } + } +} diff --git a/ios/MullvadVPNTests/OutgoingConnectionServiceTests.swift b/ios/MullvadVPNTests/OutgoingConnectionServiceTests.swift new file mode 100644 index 000000000000..9b94ebc0e8ac --- /dev/null +++ b/ios/MullvadVPNTests/OutgoingConnectionServiceTests.swift @@ -0,0 +1,56 @@ +// +// 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 { + private var mockOutgoingConnectionProxy: OutgoingConnectionProxyStub! + private var outgoingConnectionService: OutgoingConnectionService! + + override func setUp() { + mockOutgoingConnectionProxy = OutgoingConnectionProxyStub( + ipV4: .mock, + ipV6: .mock, + error: MockNetworkError.somethingWentWrong + ) + outgoingConnectionService = OutgoingConnectionService(outgoingConnectionProxy: mockOutgoingConnectionProxy) + } + + override func tearDown() { + mockOutgoingConnectionProxy = nil + outgoingConnectionService = nil + } + + func testSuccessGetOutgoingConnectionInfo() async throws { + let successExpectation = expectation(description: "should succeed") + OutgoingConnectionProxyStub.hasError = false + let result = try await outgoingConnectionService.getOutgoingConnectionInfo() + if result.ipv4 == .mock, + let ipv6 = result.ipv6, + ipv6 == .mock { + successExpectation.fulfill() + } + await fulfillment(of: [successExpectation], timeout: 1.0) + } + + func testFailureGetOutgoingConnectionInfo() async throws { + let failExpectation = expectation(description: "should fail") + OutgoingConnectionProxyStub.hasError = true + do { + _ = try await outgoingConnectionService.getOutgoingConnectionInfo() + } catch { + failExpectation.fulfill() + } + await fulfillment(of: [failExpectation], timeout: 1.0) + } +} + +enum MockNetworkError: Error { + case somethingWentWrong +}