diff --git a/ios/MullvadSettings/IPOverride.swift b/ios/MullvadSettings/IPOverride.swift new file mode 100644 index 000000000000..6a1e955c34bd --- /dev/null +++ b/ios/MullvadSettings/IPOverride.swift @@ -0,0 +1,51 @@ +// +// 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 NSError(domain: "IPOverrideInitDomain", code: NSFormattingError) + } + } + + 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 NSError(domain: "IPOverrideInitDomain", code: NSFormattingError) + } + } +} diff --git a/ios/MullvadSettings/IPOverrideRepository.swift b/ios/MullvadSettings/IPOverrideRepository.swift new file mode 100644 index 000000000000..de6295533756 --- /dev/null +++ b/ios/MullvadSettings/IPOverrideRepository.swift @@ -0,0 +1,90 @@ +// +// IPOverrideRepository.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-01-16. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +public protocol IPOverrideRepositoryProtocol { + func add(_ overrides: [IPOverride]) + func fetchAll() -> [IPOverride] + func fetchByHostname(_ hostname: String) -> IPOverride? + func deleteAll() + func parseData(_ data: Data) throws -> [IPOverride] +} + +public class IPOverrideRepository: IPOverrideRepositoryProtocol { + 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 { + print("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 { + print("Could not delete all overrides. \nError: \(error)") + } + } + + public func parseData(_ 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 0e6a5225cb8b..38163a8c9281 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -507,6 +507,13 @@ 7A5869972B32EA4500640D27 /* AppButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A5869962B32EA4500640D27 /* AppButton.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 */; }; 7A6B4F592AB8412E00123853 /* TunnelMonitorTimings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6B4F582AB8412E00123853 /* TunnelMonitorTimings.swift */; }; 7A6F2FA52AFA3CB2006D0856 /* AccountExpiryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6F2FA42AFA3CB2006D0856 /* AccountExpiryTests.swift */; }; 7A6F2FA72AFBB9AE006D0856 /* AccountExpiry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6F2FA62AFBB9AE006D0856 /* AccountExpiry.swift */; }; @@ -1673,6 +1680,13 @@ 7A5869962B32EA4500640D27 /* AppButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppButton.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 = ""; }; 7A6B4F582AB8412E00123853 /* TunnelMonitorTimings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelMonitorTimings.swift; sourceTree = ""; }; 7A6F2FA42AFA3CB2006D0856 /* AccountExpiryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountExpiryTests.swift; sourceTree = ""; }; 7A6F2FA62AFBB9AE006D0856 /* AccountExpiry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountExpiry.swift; sourceTree = ""; }; @@ -2750,6 +2764,7 @@ 58B0A2A4238EE67E00BC001D /* Info.plist */, A9B6AC192ADE8FBB00F7802A /* InMemorySettingsStore.swift */, F07BF2572A26112D00042943 /* InputTextFormatterTests.swift */, + 7A5869C22B5820CE00640D27 /* IPOverrideRepositoryTests.swift */, A9B6AC172ADE8F4300F7802A /* MigrationManagerTests.swift */, 58C3FA652A38549D006A450A /* MockFileCache.swift */, F09D04B42AE93CB6003D4F89 /* OutgoingConnectionProxy+Stub.swift */, @@ -2808,6 +2823,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 */, @@ -3286,7 +3303,11 @@ isa = PBXGroup; children = ( 7A5869AA2B55527C00640D27 /* IPOverrideCoordinator.swift */, + 7A5869BE2B57D0A100640D27 /* IPOverrideStatus.swift */, + 7A5869C02B57D21A00640D27 /* IPOverrideStatusView.swift */, + 7A5869B62B56B41500640D27 /* IPOverrideTextViewController.swift */, 7A5869AC2B5552E200640D27 /* IPOverrideViewController.swift */, + 7A5869B82B56E7F000640D27 /* IPOverrideViewControllerDelegate.swift */, ); path = IPOverride; sourceTree = ""; @@ -4499,6 +4520,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 */, @@ -4621,6 +4643,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 */, @@ -4644,6 +4667,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; @@ -4956,6 +4980,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 */, 58EFC7732AFB471500E9F4CB /* AddAccessMethodViewController.swift in Sources */, @@ -5002,6 +5027,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 */, @@ -5026,6 +5052,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 */, @@ -5046,6 +5073,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..0e338c600454 100644 --- a/ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideCoordinator.swift @@ -7,22 +7,107 @@ // import MullvadSettings +import MullvadTypes import Routing import UIKit class IPOverrideCoordinator: Coordinator, Presenting, SettingsChildCoordinator { let navigationController: UINavigationController + let repository: IPOverrideRepositoryProtocol + + lazy var ipOverrideViewController: IPOverrideViewController = { + let viewController = IPOverrideViewController(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 } func start(animated: Bool) { - let viewController = IPOverrideViewController(alertPresenter: AlertPresenter(context: self)) - navigationController.pushViewController(viewController, animated: animated) + navigationController.pushViewController(ipOverrideViewController, animated: animated) + resetToDefaultStatus() + } + + private func showImportTextView() { + let viewController = IPOverrideTextViewController() + let customNavigationController = CustomNavigationController(rootViewController: viewController) + + viewController.didFinishEditing = { [weak self] text in + if let data = text.data(using: .utf8) { + self?.handleImport(of: data, context: .text) + } else { + self?.ipOverrideViewController.setStatus(.importFailed(.text)) + print("Could not convert string to data: \(text)") + } + } + + presentationContext.present(customNavigationController, animated: true) + } + + private func showImportFileView() { + let documentPicker = UIDocumentPickerViewController(forOpeningContentTypes: [.json, .text]) + documentPicker.delegate = self + + presentationContext.present(documentPicker, animated: true) + } + + private func resetToDefaultStatus(delay: Duration = .zero) { + DispatchQueue.main.asyncAfter(deadline: .now() + delay.timeInterval) { [weak self] in + if self?.repository.fetchAll().isEmpty == false { + self?.ipOverrideViewController.setStatus(.active) + } else { + self?.ipOverrideViewController.setStatus(.noImports) + } + } + } + + private func handleImport(of data: Data, context: IPOverrideStatus.Context) { + do { + let overrides = try repository.parseData(data) + + repository.add(overrides) + ipOverrideViewController.setStatus(.importSuccessful(context)) + } catch { + ipOverrideViewController.setStatus(.importFailed(context)) + print("Error importing ip overrides: \(error)") + } + + resetToDefaultStatus(delay: .seconds(10)) + } +} + +extension IPOverrideCoordinator: IPOverrideViewControllerDelegate { + func controllerShouldShowTextImportView(_ controller: IPOverrideViewController) { + showImportTextView() + } + + func controllerShouldShowFileImportView(_ controller: IPOverrideViewController) { + showImportFileView() + } + + func controllerShouldClearAllOverrides(_ controller: IPOverrideViewController) { + repository.deleteAll() + resetToDefaultStatus() + } +} + +extension IPOverrideCoordinator: UIDocumentPickerDelegate { + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + if let url = urls.first { + do { + let data = try Data(contentsOf: url) + handleImport(of: data, context: .file) + } catch { + ipOverrideViewController.setStatus(.importFailed(.file)) + print("Could not convert file at url to data: \(url)") + } + } } } diff --git a/ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideStatus.swift b/ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideStatus.swift new file mode 100644 index 000000000000..d77dcf8f7c3f --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideStatus.swift @@ -0,0 +1,90 @@ +// +// IPOverrideStatus.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-01-17. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +enum IPOverrideStatus { + case active, noImports, importSuccessful(Context), importFailed(Context) + + enum Context { + case file, text + + var description: String { + switch self { + case .file: "of file" + case .text: "via text" + } + } + } + + var statusTitle: 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 statusIcon: UIImage? { + let titleConfiguration = UIImage.SymbolConfiguration(textStyle: .title2) + 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 statusDescription: String? { + switch self { + case .active, .noImports: + nil + 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..8f4f24c78c32 --- /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: 22, 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 = 10 + + 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.statusTitle.uppercased() + statusIcon.image = status.statusIcon + descriptionLabel.text = status.statusDescription + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideTextViewController.swift b/ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideTextViewController.swift new file mode 100644 index 000000000000..3d843be1a381 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideTextViewController.swift @@ -0,0 +1,75 @@ +// +// 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 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 + guard let self else { return } + + didFinishEditing?(textView.text) + dismiss(animated: true) + }) + ) + }() + + var didFinishEditing: ((String) -> Void)? + + 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..6016af01c496 100644 --- a/ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideViewController.swift +++ b/ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideViewController.swift @@ -11,6 +11,8 @@ import UIKit class IPOverrideViewController: UIViewController { let alertPresenter: AlertPresenter + weak var delegate: IPOverrideViewControllerDelegate? + private lazy var containerView: UIStackView = { let view = UIStackView() view.axis = .vertical @@ -30,6 +32,8 @@ class IPOverrideViewController: UIViewController { return button }() + private let statusView = IPOverrideStatusView() + init(alertPresenter: AlertPresenter) { self.alertPresenter = alertPresenter super.init(nibName: nil, bundle: nil) @@ -52,10 +56,14 @@ class IPOverrideViewController: UIViewController { view.addConstrainedSubviews([containerView, clearButton]) { containerView.pinEdgesToSuperviewMargins(.all().excluding(.bottom)) - clearButton.pinEdgesToSuperviewMargins(.all().excluding(.top)) + clearButton.pinEdgesToSuperviewMargins(.init([.leading(0), .trailing(0), .bottom(16)])) } } + func setStatus(_ status: IPOverrideStatus) { + statusView.setStatus(status) + } + private func addHeader() { let label = UILabel() label.font = .systemFont(ofSize: 32, weight: .bold) @@ -123,17 +131,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 +196,25 @@ 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 + guard let self else { return } + delegate?.controllerShouldClearAllOverrides(self) + } ), 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 +222,11 @@ class IPOverrideViewController: UIViewController { alertPresenter.showAlert(presentation: presentation, animated: true) } - @objc private func didTapImportTextButton() {} - @objc private func didTapImportFileButton() {} + @objc private func didTapImportTextButton() { + delegate?.controllerShouldShowTextImportView(self) + } + + @objc private func didTapImportFileButton() { + delegate?.controllerShouldShowFileImportView(self) + } } diff --git a/ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideViewControllerDelegate.swift b/ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideViewControllerDelegate.swift new file mode 100644 index 000000000000..2bd21778b7e7 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideViewControllerDelegate.swift @@ -0,0 +1,15 @@ +// +// 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 controllerShouldShowTextImportView(_ controller: IPOverrideViewController) + func controllerShouldShowFileImportView(_ controller: IPOverrideViewController) + func controllerShouldClearAllOverrides(_ controller: IPOverrideViewController) +} diff --git a/ios/MullvadVPN/Coordinators/Settings/SettingsCoordinator.swift b/ios/MullvadVPN/Coordinators/Settings/SettingsCoordinator.swift index 06178b93e99a..c18202e6f719 100644 --- a/ios/MullvadVPN/Coordinators/Settings/SettingsCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/Settings/SettingsCoordinator.swift @@ -259,7 +259,12 @@ 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/UI appearance/UIMetrics.swift b/ios/MullvadVPN/UI appearance/UIMetrics.swift index 33c2e69e926a..5a8128463652 100644 --- a/ios/MullvadVPN/UI appearance/UIMetrics.swift +++ b/ios/MullvadVPN/UI appearance/UIMetrics.swift @@ -116,6 +116,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 ea9eef36b019..1929b47dd947 100644 --- a/ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionContentView.swift +++ b/ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionContentView.swift @@ -70,7 +70,8 @@ class AccountDeletionContentView: UIView { value: """ 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 : + Enter the last 4 digits of the account number and hit "Delete 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..3cac8df53d8a --- /dev/null +++ b/ios/MullvadVPNTests/IPOverrideRepositoryTests.swift @@ -0,0 +1,160 @@ +// +// 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 testCanParseOverrides() throws { + XCTAssertNoThrow(try parseData(from: overrides)) + } + + func testCannotParseOverridesWithUnsupportedKeys() throws { + XCTAssertThrowsError(try parseData(from: overridesWithUnsupportedKeys)) + } + + func testCannotParseOverridesWithMalformedValues() throws { + XCTAssertThrowsError(try parseData(from: overridesWithMalformedValues)) + } + + func testCanCreateOverrideWithOneAddress() throws { + XCTAssertNoThrow(try IPOverride(hostname: "Host 1", ipv4Address: .any, ipv6Address: nil)) + XCTAssertNoThrow(try IPOverride(hostname: "Host 1", ipv4Address: nil, ipv6Address: .any)) + } + + func testCannotCreateOverrideWithNoAddresses() throws { + XCTAssertThrowsError(try IPOverride(hostname: "Host 1", ipv4Address: nil, ipv6Address: nil)) + } + + func testCanAddOverride() throws { + let override = try IPOverride(hostname: "Host 1", ipv4Address: .any, ipv6Address: nil) + repository.add([override]) + + let storedOverrides = repository.fetchAll() + XCTAssertTrue(storedOverrides.count == 1) + } + + func testCanAppendOverrideWithDifferentHostname() throws { + let override1 = try IPOverride(hostname: "Host 1", ipv4Address: .any, ipv6Address: nil) + let override2 = try IPOverride(hostname: "Host 2", ipv4Address: .any, ipv6Address: nil) + repository.add([override1, override2]) + + let storedOverrides = repository.fetchAll() + XCTAssertTrue(storedOverrides.count == 2) + } + + func testCanOverwriteOverrideWithSameHostnameButDifferentAddresses() throws { + let override1 = try IPOverride(hostname: "Host 1", ipv4Address: .any, ipv6Address: nil) + let override2 = try IPOverride(hostname: "Host 1", ipv4Address: .allHostsGroup, ipv6Address: .broadcast) + repository.add([override1, override2]) + + let storedOverrides = repository.fetchAll() + XCTAssertTrue(storedOverrides.count == 1) + XCTAssertTrue(storedOverrides.first?.ipv4Address == .allHostsGroup) + XCTAssertTrue(storedOverrides.first?.ipv6Address == .broadcast) + } + + func testCannotOverwriteOverrideWithNilAddress() throws { + let override1 = try IPOverride(hostname: "Host 1", ipv4Address: .any, ipv6Address: .broadcast) + let override2 = try IPOverride(hostname: "Host 1", ipv4Address: .any, ipv6Address: nil) + repository.add([override1, override2]) + + let storedOverrides = repository.fetchAll() + XCTAssertTrue(storedOverrides.count == 1) + XCTAssertTrue(storedOverrides.first?.ipv6Address == .broadcast) + } + + func testCanFetchOverrideByHostname() 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 testCanDeleteAllOverrides() throws { + let override = try IPOverride(hostname: "Host 1", ipv4Address: .any, ipv6Address: nil) + repository.add([override]) + repository.deleteAll() + + let storedOverrides = repository.fetchAll() + XCTAssertTrue(storedOverrides.isEmpty) + } +} + +extension IPOverrideRepositoryTests { + private func parseData(from overrideString: String) throws -> [IPOverride] { + let data = overrideString.data(using: .utf8)! + let overrides = try repository.parseData(data) + + return overrides + } +} + +extension IPOverrideRepositoryTests { + 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": "::" + }] + } + """ + } +}