diff --git a/ios/MullvadSettings/IPOverride.swift b/ios/MullvadSettings/IPOverride.swift new file mode 100644 index 000000000000..db65a9086919 --- /dev/null +++ b/ios/MullvadSettings/IPOverride.swift @@ -0,0 +1,55 @@ +// +// IPOverride.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-01-16. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Network + +public struct RelayOverrides: Codable { + public let overrides: [IPOverride] + + private enum CodingKeys: String, CodingKey { + case overrides = "relay_overrides" + } +} + +public struct IPOverride: Codable, Equatable { + public let hostname: String + public var ipv4Address: IPv4Address? + public var ipv6Address: IPv6Address? + + private enum CodingKeys: String, CodingKey { + case hostname + case ipv4Address = "ipv4_addr_in" + case ipv6Address = "ipv6_addr_in" + } + + init(hostname: String, ipv4Address: IPv4Address?, ipv6Address: IPv6Address?) throws { + self.hostname = hostname + self.ipv4Address = ipv4Address + self.ipv6Address = ipv6Address + + if self.ipv4Address.isNil && self.ipv6Address.isNil { + throw IPOverrideFormatError(errorDescription: "ipv4Address and ipv6Address cannot both be nil.") + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.hostname = try container.decode(String.self, forKey: .hostname) + self.ipv4Address = try container.decodeIfPresent(IPv4Address.self, forKey: .ipv4Address) + self.ipv6Address = try container.decodeIfPresent(IPv6Address.self, forKey: .ipv6Address) + + if self.ipv4Address.isNil && self.ipv6Address.isNil { + throw IPOverrideFormatError(errorDescription: "ipv4Address and ipv6Address cannot both be nil.") + } + } +} + +public struct IPOverrideFormatError: LocalizedError { + public let errorDescription: String? +} diff --git a/ios/MullvadSettings/IPOverrideRepository.swift b/ios/MullvadSettings/IPOverrideRepository.swift new file mode 100644 index 000000000000..75e57f77ca13 --- /dev/null +++ b/ios/MullvadSettings/IPOverrideRepository.swift @@ -0,0 +1,93 @@ +// +// IPOverrideRepository.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-01-16. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import MullvadLogging + +public protocol IPOverrideRepositoryProtocol { + func add(_ overrides: [IPOverride]) + func fetchAll() -> [IPOverride] + func fetchByHostname(_ hostname: String) -> IPOverride? + func deleteAll() + func parse(data: Data) throws -> [IPOverride] +} + +public class IPOverrideRepository: IPOverrideRepositoryProtocol { + private let logger = Logger(label: "IPOverrideRepository") + + public init() {} + + public func add(_ overrides: [IPOverride]) { + var storedOverrides = fetchAll() + + overrides.forEach { override in + if let existingOverrideIndex = storedOverrides.firstIndex(where: { $0.hostname == override.hostname }) { + var existingOverride = storedOverrides[existingOverrideIndex] + + if let ipv4Address = override.ipv4Address { + existingOverride.ipv4Address = ipv4Address + } + + if let ipv6Address = override.ipv6Address { + existingOverride.ipv6Address = ipv6Address + } + + storedOverrides[existingOverrideIndex] = existingOverride + } else { + storedOverrides.append(override) + } + } + + do { + try writeIpOverrides(storedOverrides) + } catch { + logger.error("Could not add override(s): \(overrides) \nError: \(error)") + } + } + + public func fetchAll() -> [IPOverride] { + return (try? readIpOverrides()) ?? [] + } + + public func fetchByHostname(_ hostname: String) -> IPOverride? { + return fetchAll().first { $0.hostname == hostname } + } + + public func deleteAll() { + do { + try SettingsManager.store.delete(key: .ipOverrides) + } catch { + logger.error("Could not delete all overrides. \nError: \(error)") + } + } + + public func parse(data: Data) throws -> [IPOverride] { + let decoder = JSONDecoder() + let jsonData = try decoder.decode(RelayOverrides.self, from: data) + + return jsonData.overrides + } + + private func readIpOverrides() throws -> [IPOverride] { + let parser = makeParser() + let data = try SettingsManager.store.read(key: .ipOverrides) + + return try parser.parseUnversionedPayload(as: [IPOverride].self, from: data) + } + + private func writeIpOverrides(_ overrides: [IPOverride]) throws { + let parser = makeParser() + let data = try parser.produceUnversionedPayload(overrides) + + try SettingsManager.store.write(data, for: .ipOverrides) + } + + private func makeParser() -> SettingsParser { + SettingsParser(decoder: JSONDecoder(), encoder: JSONEncoder()) + } +} diff --git a/ios/MullvadSettings/SettingsStore.swift b/ios/MullvadSettings/SettingsStore.swift index f922e3292cba..0b4c98dbb655 100644 --- a/ios/MullvadSettings/SettingsStore.swift +++ b/ios/MullvadSettings/SettingsStore.swift @@ -12,6 +12,7 @@ public enum SettingsKey: String, CaseIterable { case settings = "Settings" case deviceState = "DeviceState" case apiAccessMethods = "ApiAccessMethods" + case ipOverrides = "IPOverrides" case lastUsedAccount = "LastUsedAccount" case shouldWipeSettings = "ShouldWipeSettings" } diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index eaa1f58e832c..2e52c22f19a0 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -486,6 +486,7 @@ 7A3FD1B72AD54ABD0042BEA6 /* AnyTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58BDEB982A98F4ED00F578F2 /* AnyTransport.swift */; }; 7A3FD1B82AD54AE60042BEA6 /* TimeServerProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58BDEB9A2A98F58600F578F2 /* TimeServerProxy.swift */; }; 7A42DEC92A05164100B209BE /* SettingsInputCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A42DEC82A05164100B209BE /* SettingsInputCell.swift */; }; + 7A516C2E2B6D357500BBD33D /* URL+Scoping.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A516C2D2B6D357500BBD33D /* URL+Scoping.swift */; }; 7A5869952B32E9C700640D27 /* LinkButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A5869942B32E9C700640D27 /* LinkButton.swift */; }; 7A5869972B32EA4500640D27 /* AppButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A5869962B32EA4500640D27 /* AppButton.swift */; }; 7A58699B2B482FE200640D27 /* UITableViewCell+Disable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A58699A2B482FE200640D27 /* UITableViewCell+Disable.swift */; }; @@ -495,6 +496,13 @@ 7A5869A82B5140C200640D27 /* MethodSettingsValidationErrorContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A5869A72B5140C200640D27 /* MethodSettingsValidationErrorContentView.swift */; }; 7A5869AB2B55527C00640D27 /* IPOverrideCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A5869AA2B55527C00640D27 /* IPOverrideCoordinator.swift */; }; 7A5869AD2B5552E200640D27 /* IPOverrideViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A5869AC2B5552E200640D27 /* IPOverrideViewController.swift */; }; + 7A5869B72B56B41500640D27 /* IPOverrideTextViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A5869B62B56B41500640D27 /* IPOverrideTextViewController.swift */; }; + 7A5869B92B56E7F000640D27 /* IPOverrideViewControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A5869B82B56E7F000640D27 /* IPOverrideViewControllerDelegate.swift */; }; + 7A5869BC2B56EF3400640D27 /* IPOverrideRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A5869BA2B56EE9500640D27 /* IPOverrideRepository.swift */; }; + 7A5869BD2B56EF7300640D27 /* IPOverride.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A5869B22B5697AC00640D27 /* IPOverride.swift */; }; + 7A5869BF2B57D0A100640D27 /* IPOverrideStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A5869BE2B57D0A100640D27 /* IPOverrideStatus.swift */; }; + 7A5869C12B57D21A00640D27 /* IPOverrideStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A5869C02B57D21A00640D27 /* IPOverrideStatusView.swift */; }; + 7A5869C32B5820CE00640D27 /* IPOverrideRepositoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A5869C22B5820CE00640D27 /* IPOverrideRepositoryTests.swift */; }; 7A5869C52B5A899C00640D27 /* MethodSettingsCellConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A5869C42B5A899C00640D27 /* MethodSettingsCellConfiguration.swift */; }; 7A5869C72B5A8E4C00640D27 /* MethodSettingsDataSourceConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A5869C62B5A8E4C00640D27 /* MethodSettingsDataSourceConfiguration.swift */; }; 7A6000F62B60092F001CF0D9 /* AccessMethodViewModelEditing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6000F52B60092F001CF0D9 /* AccessMethodViewModelEditing.swift */; }; @@ -541,6 +549,8 @@ 7A9CCCC42A96302800DD6A34 /* TunnelCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9CCCB22A96302800DD6A34 /* TunnelCoordinator.swift */; }; 7A9FA1422A2E3306000B728D /* CheckboxView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9FA1412A2E3306000B728D /* CheckboxView.swift */; }; 7A9FA1442A2E3FE5000B728D /* CheckableSettingsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9FA1432A2E3FE5000B728D /* CheckableSettingsCell.swift */; }; + 7AB4CCB92B69097E006037F5 /* IPOverrideTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AB4CCB82B69097E006037F5 /* IPOverrideTests.swift */; }; + 7AB4CCBB2B691BBB006037F5 /* IPOverrideInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AB4CCBA2B691BBB006037F5 /* IPOverrideInteractor.swift */; }; 7ABCA5B32A9349F20044A708 /* Routing.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7A88DCCE2A8FABBE00D2FF0E /* Routing.framework */; }; 7ABCA5B42A9349F20044A708 /* Routing.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 7A88DCCE2A8FABBE00D2FF0E /* Routing.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 7ABCA5B72A9353C60044A708 /* Coordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CAF9F72983D36800BE19F7 /* Coordinator.swift */; }; @@ -1655,6 +1665,7 @@ 7A3353962AAA0F8600F0A71C /* OperationBlockObserverSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationBlockObserverSupport.swift; sourceTree = ""; }; 7A3FD1B42AD4465A0042BEA6 /* AppMessageHandlerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppMessageHandlerTests.swift; sourceTree = ""; }; 7A42DEC82A05164100B209BE /* SettingsInputCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsInputCell.swift; sourceTree = ""; }; + 7A516C2D2B6D357500BBD33D /* URL+Scoping.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Scoping.swift"; sourceTree = ""; }; 7A5869942B32E9C700640D27 /* LinkButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkButton.swift; sourceTree = ""; }; 7A5869962B32EA4500640D27 /* AppButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppButton.swift; sourceTree = ""; }; 7A58699A2B482FE200640D27 /* UITableViewCell+Disable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITableViewCell+Disable.swift"; sourceTree = ""; }; @@ -1664,6 +1675,13 @@ 7A5869A72B5140C200640D27 /* MethodSettingsValidationErrorContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MethodSettingsValidationErrorContentView.swift; sourceTree = ""; }; 7A5869AA2B55527C00640D27 /* IPOverrideCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPOverrideCoordinator.swift; sourceTree = ""; }; 7A5869AC2B5552E200640D27 /* IPOverrideViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPOverrideViewController.swift; sourceTree = ""; }; + 7A5869B22B5697AC00640D27 /* IPOverride.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPOverride.swift; sourceTree = ""; }; + 7A5869B62B56B41500640D27 /* IPOverrideTextViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPOverrideTextViewController.swift; sourceTree = ""; }; + 7A5869B82B56E7F000640D27 /* IPOverrideViewControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPOverrideViewControllerDelegate.swift; sourceTree = ""; }; + 7A5869BA2B56EE9500640D27 /* IPOverrideRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPOverrideRepository.swift; sourceTree = ""; }; + 7A5869BE2B57D0A100640D27 /* IPOverrideStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPOverrideStatus.swift; sourceTree = ""; }; + 7A5869C02B57D21A00640D27 /* IPOverrideStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPOverrideStatusView.swift; sourceTree = ""; }; + 7A5869C22B5820CE00640D27 /* IPOverrideRepositoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPOverrideRepositoryTests.swift; sourceTree = ""; }; 7A5869C42B5A899C00640D27 /* MethodSettingsCellConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MethodSettingsCellConfiguration.swift; sourceTree = ""; }; 7A5869C62B5A8E4C00640D27 /* MethodSettingsDataSourceConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MethodSettingsDataSourceConfiguration.swift; sourceTree = ""; }; 7A6000F52B60092F001CF0D9 /* AccessMethodViewModelEditing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessMethodViewModelEditing.swift; sourceTree = ""; }; @@ -1706,6 +1724,8 @@ 7A9CCCB22A96302800DD6A34 /* TunnelCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TunnelCoordinator.swift; sourceTree = ""; }; 7A9FA1412A2E3306000B728D /* CheckboxView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckboxView.swift; sourceTree = ""; }; 7A9FA1432A2E3FE5000B728D /* CheckableSettingsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckableSettingsCell.swift; sourceTree = ""; }; + 7AB4CCB82B69097E006037F5 /* IPOverrideTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPOverrideTests.swift; sourceTree = ""; }; + 7AB4CCBA2B691BBB006037F5 /* IPOverrideInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPOverrideInteractor.swift; sourceTree = ""; }; 7ABE318C2A1CDD4500DF4963 /* UIFont+Weight.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFont+Weight.swift"; sourceTree = ""; }; 7AC8A3AD2ABC6FBB00DC4939 /* SettingsHeaderView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsHeaderView.swift; sourceTree = ""; }; 7AD0AA192AD69B6E00119E10 /* PacketTunnelActorProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketTunnelActorProtocol.swift; sourceTree = ""; }; @@ -2431,6 +2451,7 @@ 7A58699A2B482FE200640D27 /* UITableViewCell+Disable.swift */, 7A21DACE2A30AA3700A787A9 /* UITextField+Appearance.swift */, 5878F4FF29CDA742003D4BE2 /* UIView+AutoLayoutBuilder.swift */, + 7A516C2D2B6D357500BBD33D /* URL+Scoping.swift */, ); path = Extensions; sourceTree = ""; @@ -2731,6 +2752,8 @@ 58B0A2A4238EE67E00BC001D /* Info.plist */, A9B6AC192ADE8FBB00F7802A /* InMemorySettingsStore.swift */, F07BF2572A26112D00042943 /* InputTextFormatterTests.swift */, + 7A5869C22B5820CE00640D27 /* IPOverrideRepositoryTests.swift */, + 7AB4CCB82B69097E006037F5 /* IPOverrideTests.swift */, A9B6AC172ADE8F4300F7802A /* MigrationManagerTests.swift */, 58C3FA652A38549D006A450A /* MockFileCache.swift */, F09D04B42AE93CB6003D4F89 /* OutgoingConnectionProxy+Stub.swift */, @@ -2789,6 +2812,8 @@ F0164EBB2B482E430020268D /* AppStorage.swift */, A92ECC2B2A7803A50052F1B1 /* DeviceState.swift */, 580F8B8528197958002E0998 /* DNSSettings.swift */, + 7A5869B22B5697AC00640D27 /* IPOverride.swift */, + 7A5869BA2B56EE9500640D27 /* IPOverrideRepository.swift */, 06410DFD292CE18F00AFC18C /* KeychainSettingsStore.swift */, 068CE5732927B7A400A068BB /* Migration.swift */, A9D96B192A8247C100A5C673 /* MigrationManager.swift */, @@ -3262,7 +3287,12 @@ isa = PBXGroup; children = ( 7A5869AA2B55527C00640D27 /* IPOverrideCoordinator.swift */, + 7AB4CCBA2B691BBB006037F5 /* IPOverrideInteractor.swift */, + 7A5869BE2B57D0A100640D27 /* IPOverrideStatus.swift */, + 7A5869C02B57D21A00640D27 /* IPOverrideStatusView.swift */, + 7A5869B62B56B41500640D27 /* IPOverrideTextViewController.swift */, 7A5869AC2B5552E200640D27 /* IPOverrideViewController.swift */, + 7A5869B82B56E7F000640D27 /* IPOverrideViewControllerDelegate.swift */, ); path = IPOverride; sourceTree = ""; @@ -4491,6 +4521,7 @@ A9A5FA402ACB05D90083449F /* DeviceCheckRemoteServiceProtocol.swift in Sources */, A9A5FA412ACB05D90083449F /* DeviceStateAccessor.swift in Sources */, A9A5FA422ACB05D90083449F /* DeviceStateAccessorProtocol.swift in Sources */, + 7A5869C32B5820CE00640D27 /* IPOverrideRepositoryTests.swift in Sources */, A9A5FA392ACB05910083449F /* UIColor+Palette.swift in Sources */, A9A5FA3A2ACB05910083449F /* UIEdgeInsets+Extensions.swift in Sources */, A9C342C52ACC42130045F00E /* ServerRelaysResponse+Stubs.swift in Sources */, @@ -4606,6 +4637,7 @@ 58DFF7D32B02570000F864E0 /* MarkdownStylingOptions.swift in Sources */, A9A5FA342ACB05160083449F /* StringTests.swift in Sources */, A9A5FA352ACB05160083449F /* WgKeyRotationTests.swift in Sources */, + 7AB4CCB92B69097E006037F5 /* IPOverrideTests.swift in Sources */, A9A5FA362ACB05160083449F /* TunnelManagerTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -4614,6 +4646,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 7A5869BD2B56EF7300640D27 /* IPOverride.swift in Sources */, 58B2FDEE2AA72098003EB5C6 /* ApplicationConfiguration.swift in Sources */, 58B2FDE52AA71D5C003EB5C6 /* TunnelSettingsV2.swift in Sources */, A97D30172AE6B5E90045C0E4 /* StoredWgKeyData.swift in Sources */, @@ -4637,6 +4670,7 @@ F08827892B3192110020A383 /* AccessMethodRepositoryProtocol.swift in Sources */, 58B2FDE22AA71D5C003EB5C6 /* StoredAccountData.swift in Sources */, F0D7FF902B31E00B00E0FDE5 /* AccessMethodKind.swift in Sources */, + 7A5869BC2B56EF3400640D27 /* IPOverrideRepository.swift in Sources */, 58B2FDE82AA71D5C003EB5C6 /* KeychainSettingsStore.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -4771,6 +4805,7 @@ 587B753B2666467500DEF7E9 /* NotificationBannerView.swift in Sources */, 5827B0922B0CAB2800CCBBA1 /* MethodSettingsViewController.swift in Sources */, 58B993B12608A34500BA7811 /* LoginContentView.swift in Sources */, + 7A516C2E2B6D357500BBD33D /* URL+Scoping.swift in Sources */, 5878A27529093A310096FC88 /* StorePaymentEvent.swift in Sources */, 7A7AD28D29DC677800480EF1 /* FirstTimeLaunch.swift in Sources */, 58B26E2A2943545A00D5980C /* NotificationManagerDelegate.swift in Sources */, @@ -4915,6 +4950,7 @@ 5888AD83227B11080051EB06 /* SelectLocationCell.swift in Sources */, 5891BF1C25E3E3EB006D6FB0 /* Bundle+ProductVersion.swift in Sources */, 5878A26F2907E7E00096FC88 /* ProblemReportInteractor.swift in Sources */, + 7AB4CCBB2B691BBB006037F5 /* IPOverrideInteractor.swift in Sources */, 7A3353912AAA014400F0A71C /* SimulatorVPNConnection.swift in Sources */, F028A56A2A34D4E700C0CAA3 /* RedeemVoucherViewController.swift in Sources */, 7A5869C52B5A899C00640D27 /* MethodSettingsCellConfiguration.swift in Sources */, @@ -4945,6 +4981,7 @@ 58C8191829FAA2C400DEB1B4 /* NotificationConfiguration.swift in Sources */, 58FF9FE82B07650A00E4C97D /* ButtonCellContentConfiguration.swift in Sources */, 5827B0A82B0F49EF00CCBBA1 /* ProxyConfigurationInteractorProtocol.swift in Sources */, + 7A5869B92B56E7F000640D27 /* IPOverrideViewControllerDelegate.swift in Sources */, 586C0D7A2B039CE300E7CDD7 /* ShadowsocksCipherPicker.swift in Sources */, 58EFC76A2AFAC3B800E9F4CB /* ListAccessMethodHeaderView.swift in Sources */, 58B93A1326C3F13600A55733 /* TunnelState.swift in Sources */, @@ -4987,6 +5024,7 @@ 7AF10EB42ADE85BC00C090B9 /* RelayFilterCoordinator.swift in Sources */, 58FB865526E8BF3100F188BC /* StorePaymentManagerError.swift in Sources */, F09D04B32AE919AC003D4F89 /* OutgoingConnectionProxy.swift in Sources */, + 7A5869BF2B57D0A100640D27 /* IPOverrideStatus.swift in Sources */, 58FD5BF42428C67600112C88 /* InAppPurchaseButton.swift in Sources */, 7AF10EB22ADE859200C090B9 /* AlertViewController.swift in Sources */, 587D9676288989DB00CD8F1C /* NSLayoutConstraint+Helpers.swift in Sources */, @@ -5010,6 +5048,7 @@ 5871167F2910035700D41AAC /* PreferencesInteractor.swift in Sources */, 7A9CCCC22A96302800DD6A34 /* SafariCoordinator.swift in Sources */, 58CEB3082AFD484100E6E088 /* BasicCell.swift in Sources */, + 7A5869C12B57D21A00640D27 /* IPOverrideStatusView.swift in Sources */, 58CEB2F52AFD0BB500E6E088 /* TextCellContentConfiguration.swift in Sources */, 58E20771274672CA00DE5D77 /* LaunchViewController.swift in Sources */, F0E8CC032A4C753B007ED3B4 /* WelcomeViewController.swift in Sources */, @@ -5033,6 +5072,7 @@ 585B1FF02AB09F97008AD470 /* VPNConnectionProtocol.swift in Sources */, 58C3A4B222456F1B00340BDB /* AccountInputGroupView.swift in Sources */, F09A297C2A9F8A9B00EA3B6F /* VoucherTextField.swift in Sources */, + 7A5869B72B56B41500640D27 /* IPOverrideTextViewController.swift in Sources */, 58ACF64B26553C3F00ACE4B7 /* SettingsSwitchCell.swift in Sources */, 7AF9BE952A40461100DBFEDB /* RelayFilterView.swift in Sources */, 7A09C98129D99215000C2CAC /* String+FuzzyMatch.swift in Sources */, diff --git a/ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideCoordinator.swift b/ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideCoordinator.swift index 8ba9a072a412..53ffe916131c 100644 --- a/ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideCoordinator.swift @@ -7,22 +7,45 @@ // import MullvadSettings +import MullvadTypes import Routing import UIKit class IPOverrideCoordinator: Coordinator, Presenting, SettingsChildCoordinator { - let navigationController: UINavigationController + private let navigationController: UINavigationController + private let interactor: IPOverrideInteractor + private let repository: IPOverrideRepositoryProtocol + + private lazy var ipOverrideViewController: IPOverrideViewController = { + let viewController = IPOverrideViewController( + interactor: interactor, + alertPresenter: AlertPresenter(context: self) + ) + viewController.delegate = self + return viewController + }() var presentationContext: UIViewController { navigationController } - init(navigationController: UINavigationController) { + init(navigationController: UINavigationController, repository: IPOverrideRepositoryProtocol) { self.navigationController = navigationController + self.repository = repository + + interactor = IPOverrideInteractor(repository: repository) } func start(animated: Bool) { - let viewController = IPOverrideViewController(alertPresenter: AlertPresenter(context: self)) - navigationController.pushViewController(viewController, animated: animated) + navigationController.pushViewController(ipOverrideViewController, animated: animated) + } +} + +extension IPOverrideCoordinator: IPOverrideViewControllerDelegate { + func presentImportTextController() { + let viewController = IPOverrideTextViewController(interactor: interactor) + let customNavigationController = CustomNavigationController(rootViewController: viewController) + + presentationContext.present(customNavigationController, animated: true) } } diff --git a/ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideInteractor.swift b/ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideInteractor.swift new file mode 100644 index 000000000000..f400e7849f7b --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideInteractor.swift @@ -0,0 +1,73 @@ +// +// IPOverrideInteractor.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-01-30. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Combine +import MullvadLogging +import MullvadSettings +import MullvadTypes + +struct IPOverrideInteractor { + private let logger = Logger(label: "IPOverrideInteractor") + private let repository: IPOverrideRepositoryProtocol + + private let statusSubject = CurrentValueSubject(.noImports) + var statusPublisher: AnyPublisher { + statusSubject.eraseToAnyPublisher() + } + + var defaultStatus: IPOverrideStatus { + if repository.fetchAll().isEmpty { + return .noImports + } else { + return .active + } + } + + init(repository: IPOverrideRepositoryProtocol) { + self.repository = repository + + resetToDefaultStatus() + } + + func `import`(url: URL) { + let data = (try? Data(contentsOf: url)) ?? Data() + handleImport(of: data, context: .file) + } + + func `import`(text: String) { + let data = text.data(using: .utf8) ?? Data() + handleImport(of: data, context: .text) + } + + func deleteAllOverrides() { + repository.deleteAll() + resetToDefaultStatus() + } + + private func handleImport(of data: Data, context: IPOverrideStatus.Context) { + do { + let overrides = try repository.parse(data: data) + + repository.add(overrides) + statusSubject.send(.importSuccessful(context)) + } catch { + statusSubject.send(.importFailed(context)) + logger.error("Error importing ip overrides: \(error)") + } + + // After an import - successful or not - the UI should be reset back to default + // state after a certain amount of time. + resetToDefaultStatus(delay: .seconds(10)) + } + + private func resetToDefaultStatus(delay: Duration = .zero) { + DispatchQueue.main.asyncAfter(deadline: .now() + delay.timeInterval) { + statusSubject.send(defaultStatus) + } + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideStatus.swift b/ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideStatus.swift new file mode 100644 index 000000000000..85cc8d3fc5b6 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideStatus.swift @@ -0,0 +1,91 @@ +// +// IPOverrideStatus.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-01-17. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +enum IPOverrideStatus: CustomStringConvertible { + case active, noImports, importSuccessful(Context), importFailed(Context) + + enum Context { + case file, text + + // Used in "statusDescription" below to form a complete sentence and therefore not localized here. + var description: String { + switch self { + case .file: "of file" + case .text: "via text" + } + } + } + + var title: String { + switch self { + case .active: + NSLocalizedString( + "IP_OVERRIDE_STATUS_TITLE_ACTIVE", + tableName: "IPOverride", + value: "Overrides active", + comment: "" + ) + case .noImports, .importFailed: + NSLocalizedString( + "IP_OVERRIDE_STATUS_TITLE_NO_IMPORTS", + tableName: "IPOverride", + value: "No overrides imported", + comment: "" + ) + case .importSuccessful: + NSLocalizedString( + "IP_OVERRIDE_STATUS_TITLE_IMPORT_SUCCESSFUL", + tableName: "IPOverride", + value: "Import successful", + comment: "" + ) + } + } + + var icon: UIImage? { + let titleConfiguration = UIImage.SymbolConfiguration(textStyle: .body) + let weightConfiguration = UIImage.SymbolConfiguration(weight: .bold) + let combinedConfiguration = titleConfiguration.applying(weightConfiguration) + + switch self { + case .active, .noImports: + return nil + case .importFailed: + return UIImage(systemName: "xmark", withConfiguration: combinedConfiguration)? + .withRenderingMode(.alwaysOriginal) + .withTintColor(.dangerColor) + case .importSuccessful: + return UIImage(systemName: "checkmark", withConfiguration: combinedConfiguration)? + .withRenderingMode(.alwaysOriginal) + .withTintColor(.successColor) + } + } + + var description: String { + switch self { + case .active, .noImports: + "" + case let .importFailed(context): + NSLocalizedString( + "IP_OVERRIDE_STATUS_DESCRIPTION_INACTIVE", + tableName: "IPOverride", + value: "Import \(context.description) was unsuccessful, please try again.", + comment: "" + ) + case let .importSuccessful(context): + NSLocalizedString( + "IP_OVERRIDE_STATUS_DESCRIPTION_INACTIVE", + tableName: "IPOverride", + value: "Import \(context.description) was successful, overrides are now active.", + comment: "" + ) + } + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideStatusView.swift b/ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideStatusView.swift new file mode 100644 index 000000000000..9c9838b6eafa --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideStatusView.swift @@ -0,0 +1,57 @@ +// +// IPOverrideStatusView.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-01-17. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +class IPOverrideStatusView: UIView { + private lazy var titleLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 15, weight: .bold) + label.textColor = .white + return label + }() + + private lazy var statusIcon: UIImageView = { + return UIImageView() + }() + + private lazy var descriptionLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 12, weight: .semibold) + label.textColor = .white.withAlphaComponent(0.6) + return label + }() + + init() { + super.init(frame: .zero) + + let titleContainerView = UIStackView(arrangedSubviews: [titleLabel, statusIcon, UIView()]) + titleContainerView.spacing = 6 + + let contentContainterView = UIStackView(arrangedSubviews: [ + titleContainerView, + descriptionLabel, + ]) + contentContainterView.axis = .vertical + contentContainterView.spacing = 4 + + addConstrainedSubviews([contentContainterView]) { + contentContainterView.pinEdgesToSuperview() + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setStatus(_ status: IPOverrideStatus) { + titleLabel.text = status.title.uppercased() + statusIcon.image = status.icon + descriptionLabel.text = status.description + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideTextViewController.swift b/ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideTextViewController.swift new file mode 100644 index 000000000000..1687ff59c59b --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideTextViewController.swift @@ -0,0 +1,83 @@ +// +// IPOverrideTextViewController.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-01-16. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +class IPOverrideTextViewController: UIViewController { + private let interactor: IPOverrideInteractor + private var textView = CustomTextView() + + private lazy var importButton: UIBarButtonItem = { + return UIBarButtonItem( + title: NSLocalizedString( + "IMPORT_TEXT_IMPORT_BUTTON", + tableName: "IPOverride", + value: "Import", + comment: "" + ), + primaryAction: UIAction(handler: { [weak self] _ in + self?.interactor.import(text: self?.textView.text ?? "") + self?.dismiss(animated: true) + + }) + ) + }() + + init(interactor: IPOverrideInteractor) { + self.interactor = interactor + + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .secondaryColor + + navigationItem.title = NSLocalizedString( + "IMPORT_TEXT_NAVIGATION_TITLE", + tableName: "IPOverride", + value: "Import via text", + comment: "" + ) + + navigationItem.leftBarButtonItem = UIBarButtonItem( + systemItem: .cancel, + primaryAction: UIAction(handler: { [weak self] _ in + self?.dismiss(animated: true) + }) + ) + + importButton.isEnabled = !textView.text.isEmpty + navigationItem.rightBarButtonItem = importButton + + textView.becomeFirstResponder() + textView.delegate = self + textView.spellCheckingType = .no + textView.autocorrectionType = .no + textView.font = UIFont.monospacedSystemFont( + ofSize: UIFont.systemFont(ofSize: 14).pointSize, + weight: .regular + ) + + view.addConstrainedSubviews([textView]) { + textView.pinEdgesToSuperview(.all().excluding(.top)) + textView.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor, constant: 0) + } + } +} + +extension IPOverrideTextViewController: UITextViewDelegate { + func textViewDidChange(_ textView: UITextView) { + importButton.isEnabled = !textView.text.isEmpty + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideViewController.swift b/ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideViewController.swift index 80a675b93645..b4ba782c0f04 100644 --- a/ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideViewController.swift +++ b/ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideViewController.swift @@ -6,10 +6,15 @@ // Copyright © 2024 Mullvad VPN AB. All rights reserved. // +import Combine import UIKit class IPOverrideViewController: UIViewController { - let alertPresenter: AlertPresenter + private let interactor: IPOverrideInteractor + private var cancellables = Set() + private let alertPresenter: AlertPresenter + + weak var delegate: IPOverrideViewControllerDelegate? private lazy var containerView: UIStackView = { let view = UIStackView() @@ -30,8 +35,12 @@ class IPOverrideViewController: UIViewController { return button }() - init(alertPresenter: AlertPresenter) { + private let statusView = IPOverrideStatusView() + + init(interactor: IPOverrideInteractor, alertPresenter: AlertPresenter) { + self.interactor = interactor self.alertPresenter = alertPresenter + super.init(nibName: nil, bundle: nil) } @@ -42,7 +51,7 @@ class IPOverrideViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - navigationController?.navigationBar.prefersLargeTitles = false + navigationController?.navigationItem.largeTitleDisplayMode = .never view.backgroundColor = .secondaryColor addHeader() @@ -52,8 +61,12 @@ class IPOverrideViewController: UIViewController { view.addConstrainedSubviews([containerView, clearButton]) { containerView.pinEdgesToSuperviewMargins(.all().excluding(.bottom)) - clearButton.pinEdgesToSuperviewMargins(.all().excluding(.top)) + clearButton.pinEdgesToSuperviewMargins(PinnableEdges([.leading(0), .trailing(0), .bottom(16)])) } + + interactor.statusPublisher.sink { [weak self] status in + self?.statusView.setStatus(status) + }.store(in: &cancellables) } private func addHeader() { @@ -123,17 +136,7 @@ class IPOverrideViewController: UIViewController { } private func addStatusLabel() { - let label = UILabel() - label.font = .systemFont(ofSize: 22, weight: .bold) - label.textColor = .white - label.text = NSLocalizedString( - "IP_OVERRIDE_STATUS", - tableName: "IPOverride", - value: "Overrides active", - comment: "" - ).uppercased() - - containerView.addArrangedSubview(label) + containerView.addArrangedSubview(statusView) } @objc private func didTapInfoButton() { @@ -198,21 +201,24 @@ class IPOverrideViewController: UIViewController { buttons: [ AlertAction( title: NSLocalizedString( - "IP_OVERRIDE_CLEAR_DIALOG_CANCEL_BUTTON", + "IP_OVERRIDE_CLEAR_DIALOG_CLEAR_BUTTON", tableName: "IPOverride", - value: "Cancel", + value: "Clear", comment: "" ), - style: .default + style: .destructive, + handler: { [weak self] in + self?.interactor.deleteAllOverrides() + } ), AlertAction( title: NSLocalizedString( - "IP_OVERRIDE_CLEAR_DIALOG_CLEAR_BUTTON", + "IP_OVERRIDE_CLEAR_DIALOG_CANCEL_BUTTON", tableName: "IPOverride", - value: "Clear", + value: "Cancel", comment: "" ), - style: .destructive + style: .default ), ] ) @@ -220,6 +226,24 @@ class IPOverrideViewController: UIViewController { alertPresenter.showAlert(presentation: presentation, animated: true) } - @objc private func didTapImportTextButton() {} - @objc private func didTapImportFileButton() {} + @objc private func didTapImportTextButton() { + delegate?.presentImportTextController() + } + + @objc private func didTapImportFileButton() { + let documentPicker = UIDocumentPickerViewController(forOpeningContentTypes: [.json, .text]) + documentPicker.delegate = self + + present(documentPicker, animated: true) + } +} + +extension IPOverrideViewController: UIDocumentPickerDelegate { + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + if let url = urls.first { + url.securelyScoped { [weak self] url in + self?.interactor.import(url: url) + } + } + } } diff --git a/ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideViewControllerDelegate.swift b/ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideViewControllerDelegate.swift new file mode 100644 index 000000000000..d71de543c4bf --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideViewControllerDelegate.swift @@ -0,0 +1,13 @@ +// +// IPOverrideViewControllerDelegate.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-01-16. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +protocol IPOverrideViewControllerDelegate: AnyObject { + func presentImportTextController() +} diff --git a/ios/MullvadVPN/Coordinators/Settings/SettingsCoordinator.swift b/ios/MullvadVPN/Coordinators/Settings/SettingsCoordinator.swift index b2d7d3dfc6dd..46452201526f 100644 --- a/ios/MullvadVPN/Coordinators/Settings/SettingsCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/Settings/SettingsCoordinator.swift @@ -263,7 +263,10 @@ final class SettingsCoordinator: Coordinator, Presentable, Presenting, SettingsV )) case .ipOverride: - return .childCoordinator(IPOverrideCoordinator(navigationController: navigationController)) + return .childCoordinator(IPOverrideCoordinator( + navigationController: navigationController, + repository: IPOverrideRepository() + )) case .faq: // Handled separately and presented as a modal. diff --git a/ios/MullvadVPN/Extensions/URL+Scoping.swift b/ios/MullvadVPN/Extensions/URL+Scoping.swift new file mode 100644 index 000000000000..5f5b68637c7c --- /dev/null +++ b/ios/MullvadVPN/Extensions/URL+Scoping.swift @@ -0,0 +1,18 @@ +// +// URL+Scoping.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-02-02. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +extension URL { + func securelyScoped(_ completionHandler: (Self) -> Void) { + if startAccessingSecurityScopedResource() { + completionHandler(self) + stopAccessingSecurityScopedResource() + } + } +} diff --git a/ios/MullvadVPN/UI appearance/UIMetrics.swift b/ios/MullvadVPN/UI appearance/UIMetrics.swift index a04e1e1ae041..0e6ea7d00cc7 100644 --- a/ios/MullvadVPN/UI appearance/UIMetrics.swift +++ b/ios/MullvadVPN/UI appearance/UIMetrics.swift @@ -122,6 +122,9 @@ extension UIMetrics { /// Text field margins static let textFieldMargins = UIEdgeInsets(top: 12, left: 14, bottom: 12, right: 14) + /// Text view margins + static let textViewMargins = UIEdgeInsets(top: 14, left: 14, bottom: 14, right: 14) + /// Corner radius used for controls such as buttons and text fields static let controlCornerRadius: CGFloat = 4 diff --git a/ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionContentView.swift b/ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionContentView.swift index 3dc3572e44f7..1929b47dd947 100644 --- a/ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionContentView.swift +++ b/ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionContentView.swift @@ -71,7 +71,7 @@ class AccountDeletionContentView: UIView { This logs out all devices using this account and all \ VPN access will be denied even if there is time left on the account. \ Enter the last 4 digits of the account number and hit "Delete account" \ - if you really want to delete the account : + if you really want to delete the account: """, comment: "" ) diff --git a/ios/MullvadVPNTests/IPOverrideRepositoryTests.swift b/ios/MullvadVPNTests/IPOverrideRepositoryTests.swift new file mode 100644 index 000000000000..0e90a79e21fb --- /dev/null +++ b/ios/MullvadVPNTests/IPOverrideRepositoryTests.swift @@ -0,0 +1,87 @@ +// +// IPOverrideRepositoryTests.swift +// MullvadVPNTests +// +// Created by Jon Petersson on 2024-01-17. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +@testable import MullvadSettings +import Network +import XCTest + +final class IPOverrideRepositoryTests: XCTestCase { + static let store = InMemorySettingsStore() + let repository = IPOverrideRepository() + + override class func setUp() { + SettingsManager.unitTestStore = store + } + + override class func tearDown() { + SettingsManager.unitTestStore = nil + } + + override func tearDownWithError() throws { + repository.deleteAll() + } + + func testAddOverride() throws { + let override = try IPOverride(hostname: "Host 1", ipv4Address: .any, ipv6Address: nil) + repository.add([override]) + + let storedOverrides = repository.fetchAll() + XCTAssertTrue(storedOverrides.count == 1) + } + + func testAppendOverrideWithDifferentHostname() throws { + let override1 = try IPOverride(hostname: "Host 1", ipv4Address: .any, ipv6Address: nil) + repository.add([override1]) + let override2 = try IPOverride(hostname: "Host 2", ipv4Address: .any, ipv6Address: nil) + repository.add([override2]) + + let storedOverrides = repository.fetchAll() + XCTAssertTrue(storedOverrides.count == 2) + } + + func testOverwriteOverrideWithSameHostnameButDifferentAddresses() throws { + let override1 = try IPOverride(hostname: "Host 1", ipv4Address: .any, ipv6Address: nil) + repository.add([override1]) + let override2 = try IPOverride(hostname: "Host 1", ipv4Address: .allHostsGroup, ipv6Address: .broadcast) + repository.add([override2]) + + let storedOverrides = repository.fetchAll() + XCTAssertTrue(storedOverrides.count == 1) + XCTAssertTrue(storedOverrides.first?.ipv4Address == .allHostsGroup) + XCTAssertTrue(storedOverrides.first?.ipv6Address == .broadcast) + } + + func testFailedToOverwriteOverrideWithNilAddress() throws { + let override1 = try IPOverride(hostname: "Host 1", ipv4Address: .any, ipv6Address: .broadcast) + repository.add([override1]) + let override2 = try IPOverride(hostname: "Host 1", ipv4Address: .any, ipv6Address: nil) + repository.add([override2]) + + let storedOverrides = repository.fetchAll() + XCTAssertTrue(storedOverrides.count == 1) + XCTAssertTrue(storedOverrides.first?.ipv6Address == .broadcast) + } + + func testFetchOverrideByHostname() throws { + let hostname = "Host 1" + let override = try IPOverride(hostname: hostname, ipv4Address: .any, ipv6Address: nil) + repository.add([override]) + + let storedOverride = repository.fetchByHostname(hostname) + XCTAssertTrue(storedOverride?.hostname == hostname) + } + + func testDeleteAllOverrides() throws { + let override = try IPOverride(hostname: "Host 1", ipv4Address: .any, ipv6Address: nil) + repository.add([override]) + repository.deleteAll() + + let storedOverrides = repository.fetchAll() + XCTAssertTrue(storedOverrides.isEmpty) + } +} diff --git a/ios/MullvadVPNTests/IPOverrideTests.swift b/ios/MullvadVPNTests/IPOverrideTests.swift new file mode 100644 index 000000000000..0cb940f1ce24 --- /dev/null +++ b/ios/MullvadVPNTests/IPOverrideTests.swift @@ -0,0 +1,103 @@ +// +// IPOverrideTests.swift +// MullvadVPNTests +// +// Created by Jon Petersson on 2024-01-30. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +@testable import MullvadSettings +import MullvadTypes +import XCTest + +final class IPOverrideTests: XCTestCase { + let repository = IPOverrideRepository() + + func testCanParseOverrides() throws { + XCTAssertNoThrow(try parseData(from: overrides)) + } + + func testCanParseOverrideToInternalType() throws { + let overrides = try parseData(from: overrides) + overrides.forEach { override in + if let ipv4Address = override.ipv4Address { + XCTAssertNotNil(AnyIPAddress(ipv4Address.debugDescription)) + } + if let ipv6Address = override.ipv6Address { + XCTAssertNotNil(AnyIPAddress(ipv6Address.debugDescription)) + } + } + } + + func testFailedToParseOverridesWithUnsupportedKeys() throws { + XCTAssertThrowsError(try parseData(from: overridesWithUnsupportedKeys)) + } + + func testFailedToParseOverridesWithMalformedValues() throws { + XCTAssertThrowsError(try parseData(from: overridesWithMalformedValues)) + } + + func testCreateOverrideWithOneAddress() throws { + XCTAssertNoThrow(try IPOverride(hostname: "Host 1", ipv4Address: .any, ipv6Address: nil)) + XCTAssertNoThrow(try IPOverride(hostname: "Host 1", ipv4Address: nil, ipv6Address: .any)) + } + + func testFailedToCreateOverrideWithNoAddresses() throws { + XCTAssertThrowsError(try IPOverride(hostname: "Host 1", ipv4Address: nil, ipv6Address: nil)) + } +} + +extension IPOverrideTests { + private func parseData(from overrideString: String) throws -> [IPOverride] { + let data = overrideString.data(using: .utf8)! + let overrides = try repository.parse(data: data) + + return overrides + } +} + +extension IPOverrideTests { + private var overrides: String { + return """ + { + "relay_overrides": [ + { + "hostname": "Host 1", + "ipv4_addr_in": "127.0.0.1", + "ipv6_addr_in": "::" + }, + { + "hostname": "Host 2", + "ipv4_addr_in": "127.0.0.2", + "ipv6_addr_in": "::1" + } + ] + } + """ + } + + private var overridesWithUnsupportedKeys: String { + return """ + "{ + "relay_overrides": [{ + "name": "Host 1", + "hostname": "Host 1", + "ipv4_addr_in": "127.0.0.1", + "ipv6_addr_in": "::" + }] + } + """ + } + + private var overridesWithMalformedValues: String { + return """ + "{ + "relay_overrides": [{ + "hostname": "Host 1", + "ipv4_addr_in": "127.0.0", + "ipv6_addr_in": "::" + }] + } + """ + } +}