summaryrefslogtreecommitdiffhomepage
path: root/ios
diff options
context:
space:
mode:
authorBug Magnet <marco.nikic@mullvad.net>2024-02-07 09:17:40 +0100
committerBug Magnet <marco.nikic@mullvad.net>2024-02-07 09:17:40 +0100
commit446ef2b026bebce7d877226a7db0551496acbf28 (patch)
tree98c1ab4c48645142b0d8bd75591db61ffa30e276 /ios
parent15b5cb522ed4825f538d22df37c331d739435b03 (diff)
parent9b86bcbd7145c13de036c16f6e36e1ea1f2c1ce1 (diff)
downloadmullvadvpn-446ef2b026bebce7d877226a7db0551496acbf28.tar.xz
mullvadvpn-446ef2b026bebce7d877226a7db0551496acbf28.zip
Merge branch 'allow-users-to-import-settings-by-pasting-json-blobs-ios-451'
Diffstat (limited to 'ios')
-rw-r--r--ios/MullvadSettings/IPOverride.swift55
-rw-r--r--ios/MullvadSettings/IPOverrideRepository.swift93
-rw-r--r--ios/MullvadSettings/SettingsStore.swift1
-rw-r--r--ios/MullvadVPN.xcodeproj/project.pbxproj40
-rw-r--r--ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideCoordinator.swift31
-rw-r--r--ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideInteractor.swift73
-rw-r--r--ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideStatus.swift91
-rw-r--r--ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideStatusView.swift57
-rw-r--r--ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideTextViewController.swift83
-rw-r--r--ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideViewController.swift70
-rw-r--r--ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideViewControllerDelegate.swift13
-rw-r--r--ios/MullvadVPN/Coordinators/Settings/SettingsCoordinator.swift5
-rw-r--r--ios/MullvadVPN/Extensions/URL+Scoping.swift18
-rw-r--r--ios/MullvadVPN/UI appearance/UIMetrics.swift3
-rw-r--r--ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionContentView.swift2
-rw-r--r--ios/MullvadVPNTests/IPOverrideRepositoryTests.swift87
-rw-r--r--ios/MullvadVPNTests/IPOverrideTests.swift103
17 files changed, 796 insertions, 29 deletions
diff --git a/ios/MullvadSettings/IPOverride.swift b/ios/MullvadSettings/IPOverride.swift
new file mode 100644
index 0000000000..db65a90869
--- /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 0000000000..75e57f77ca
--- /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 f922e3292c..0b4c98dbb6 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 075309bfee..5036a52bbd 100644
--- a/ios/MullvadVPN.xcodeproj/project.pbxproj
+++ b/ios/MullvadVPN.xcodeproj/project.pbxproj
@@ -485,6 +485,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 */; };
@@ -494,6 +495,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 */; };
@@ -540,6 +548,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 = "<group>"; };
7A3FD1B42AD4465A0042BEA6 /* AppMessageHandlerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppMessageHandlerTests.swift; sourceTree = "<group>"; };
7A42DEC82A05164100B209BE /* SettingsInputCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsInputCell.swift; sourceTree = "<group>"; };
+ 7A516C2D2B6D357500BBD33D /* URL+Scoping.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Scoping.swift"; sourceTree = "<group>"; };
7A5869942B32E9C700640D27 /* LinkButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkButton.swift; sourceTree = "<group>"; };
7A5869962B32EA4500640D27 /* AppButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppButton.swift; sourceTree = "<group>"; };
7A58699A2B482FE200640D27 /* UITableViewCell+Disable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITableViewCell+Disable.swift"; sourceTree = "<group>"; };
@@ -1664,6 +1675,13 @@
7A5869A72B5140C200640D27 /* MethodSettingsValidationErrorContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MethodSettingsValidationErrorContentView.swift; sourceTree = "<group>"; };
7A5869AA2B55527C00640D27 /* IPOverrideCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPOverrideCoordinator.swift; sourceTree = "<group>"; };
7A5869AC2B5552E200640D27 /* IPOverrideViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPOverrideViewController.swift; sourceTree = "<group>"; };
+ 7A5869B22B5697AC00640D27 /* IPOverride.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPOverride.swift; sourceTree = "<group>"; };
+ 7A5869B62B56B41500640D27 /* IPOverrideTextViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPOverrideTextViewController.swift; sourceTree = "<group>"; };
+ 7A5869B82B56E7F000640D27 /* IPOverrideViewControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPOverrideViewControllerDelegate.swift; sourceTree = "<group>"; };
+ 7A5869BA2B56EE9500640D27 /* IPOverrideRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPOverrideRepository.swift; sourceTree = "<group>"; };
+ 7A5869BE2B57D0A100640D27 /* IPOverrideStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPOverrideStatus.swift; sourceTree = "<group>"; };
+ 7A5869C02B57D21A00640D27 /* IPOverrideStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPOverrideStatusView.swift; sourceTree = "<group>"; };
+ 7A5869C22B5820CE00640D27 /* IPOverrideRepositoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPOverrideRepositoryTests.swift; sourceTree = "<group>"; };
7A5869C42B5A899C00640D27 /* MethodSettingsCellConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MethodSettingsCellConfiguration.swift; sourceTree = "<group>"; };
7A5869C62B5A8E4C00640D27 /* MethodSettingsDataSourceConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MethodSettingsDataSourceConfiguration.swift; sourceTree = "<group>"; };
7A6000F52B60092F001CF0D9 /* AccessMethodViewModelEditing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessMethodViewModelEditing.swift; sourceTree = "<group>"; };
@@ -1706,6 +1724,8 @@
7A9CCCB22A96302800DD6A34 /* TunnelCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TunnelCoordinator.swift; sourceTree = "<group>"; };
7A9FA1412A2E3306000B728D /* CheckboxView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckboxView.swift; sourceTree = "<group>"; };
7A9FA1432A2E3FE5000B728D /* CheckableSettingsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckableSettingsCell.swift; sourceTree = "<group>"; };
+ 7AB4CCB82B69097E006037F5 /* IPOverrideTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPOverrideTests.swift; sourceTree = "<group>"; };
+ 7AB4CCBA2B691BBB006037F5 /* IPOverrideInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPOverrideInteractor.swift; sourceTree = "<group>"; };
7ABE318C2A1CDD4500DF4963 /* UIFont+Weight.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFont+Weight.swift"; sourceTree = "<group>"; };
7AC8A3AD2ABC6FBB00DC4939 /* SettingsHeaderView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsHeaderView.swift; sourceTree = "<group>"; };
7AD0AA192AD69B6E00119E10 /* PacketTunnelActorProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketTunnelActorProtocol.swift; sourceTree = "<group>"; };
@@ -2424,6 +2444,7 @@
7A58699A2B482FE200640D27 /* UITableViewCell+Disable.swift */,
7A21DACE2A30AA3700A787A9 /* UITextField+Appearance.swift */,
5878F4FF29CDA742003D4BE2 /* UIView+AutoLayoutBuilder.swift */,
+ 7A516C2D2B6D357500BBD33D /* URL+Scoping.swift */,
);
path = Extensions;
sourceTree = "<group>";
@@ -2724,6 +2745,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 */,
@@ -2782,6 +2805,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 */,
@@ -3256,7 +3281,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 = "<group>";
@@ -4484,6 +4514,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 */,
@@ -4599,6 +4630,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;
@@ -4607,6 +4639,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 */,
@@ -4630,6 +4663,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;
@@ -4766,6 +4800,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 */,
@@ -4910,6 +4945,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 */,
@@ -4940,6 +4976,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 */,
@@ -4982,6 +5019,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 */,
@@ -5005,6 +5043,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 */,
@@ -5028,6 +5067,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 8ba9a072a4..53ffe91613 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 0000000000..f400e7849f
--- /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<IPOverrideStatus, Never>(.noImports)
+ var statusPublisher: AnyPublisher<IPOverrideStatus, Never> {
+ 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 0000000000..85cc8d3fc5
--- /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 0000000000..9c9838b6ea
--- /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 0000000000..1687ff59c5
--- /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 80a675b936..b4ba782c0f 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<AnyCancellable>()
+ 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 0000000000..d71de543c4
--- /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 b2d7d3dfc6..4645220152 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 0000000000..5f5b68637c
--- /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 a04e1e1ae0..0e6ea7d00c 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 3dc3572e44..1929b47dd9 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 0000000000..0e90a79e21
--- /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<SettingNotFound>()
+ 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 0000000000..0cb940f1ce
--- /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": "::"
+ }]
+ }
+ """
+ }
+}