diff --git a/ios/MullvadSettings/AccessMethodRepository.swift b/ios/MullvadSettings/AccessMethodRepository.swift index 6f2b253b2f63..0b03f03817bc 100644 --- a/ios/MullvadSettings/AccessMethodRepository.swift +++ b/ios/MullvadSettings/AccessMethodRepository.swift @@ -8,22 +8,27 @@ import Combine import Foundation +import MullvadLogging public class AccessMethodRepository: AccessMethodRepositoryProtocol { - let passthroughSubject: CurrentValueSubject<[PersistentAccessMethod], Never> = CurrentValueSubject([]) + private let logger = Logger(label: "AccessMethodRepository") + private let direct = PersistentAccessMethod( id: UUID(uuidString: "C9DB7457-2A55-42C3-A926-C07F82131994")!, - name: "", + name: "Direct", isEnabled: true, proxyConfiguration: .direct ) + private let bridge = PersistentAccessMethod( id: UUID(uuidString: "8586E75A-CA7B-4432-B70D-EE65F3F95084")!, - name: "", + name: "Mullvad bridges", isEnabled: true, proxyConfiguration: .bridges ) + let passthroughSubject: CurrentValueSubject<[PersistentAccessMethod], Never> = CurrentValueSubject([]) + public var publisher: AnyPublisher<[PersistentAccessMethod], Never> { passthroughSubject.eraseToAnyPublisher() } @@ -36,35 +41,19 @@ public class AccessMethodRepository: AccessMethodRepositoryProtocol { add([direct, bridge]) } - public func add(_ method: PersistentAccessMethod) { - add([method]) - } - - public func add(_ methods: [PersistentAccessMethod]) { + public func save(_ method: PersistentAccessMethod) { var storedMethods = fetchAll() - methods.forEach { method in - guard !storedMethods.contains(where: { $0.id == method.id }) else { return } + if let index = storedMethods.firstIndex(where: { $0.id == method.id }) { + storedMethods[index] = method + } else { storedMethods.append(method) } do { try writeApiAccessMethods(storedMethods) } catch { - print("Could not add access method(s): \(methods) \nError: \(error)") - } - } - - public func update(_ method: PersistentAccessMethod) { - var methods = fetchAll() - - guard let index = methods.firstIndex(where: { $0.id == method.id }) else { return } - methods[index] = method - - do { - try writeApiAccessMethods(methods) - } catch { - print("Could not update access method: \(method) \nError: \(error)") + logger.error("Could not update access methods: \(storedMethods) \nError: \(error)") } } @@ -81,7 +70,7 @@ public class AccessMethodRepository: AccessMethodRepositoryProtocol { do { try writeApiAccessMethods(methods) } catch { - print("Could not delete access method with id: \(id) \nError: \(error)") + logger.error("Could not delete access method with id: \(id) \nError: \(error)") } } @@ -97,6 +86,22 @@ public class AccessMethodRepository: AccessMethodRepositoryProtocol { add([direct, bridge]) } + private func add(_ methods: [PersistentAccessMethod]) { + var storedMethods = fetchAll() + + methods.forEach { method in + if !storedMethods.contains(where: { $0.id == method.id }) { + storedMethods.append(method) + } + } + + do { + try writeApiAccessMethods(storedMethods) + } catch { + logger.error("Could not update access methods: \(storedMethods) \nError: \(error)") + } + } + private func readApiAccessMethods() throws -> [PersistentAccessMethod] { let parser = makeParser() let data = try SettingsManager.store.read(key: .apiAccessMethods) diff --git a/ios/MullvadSettings/AccessMethodRepositoryProtocol.swift b/ios/MullvadSettings/AccessMethodRepositoryProtocol.swift index 9b0a255b9984..037ea24cda89 100644 --- a/ios/MullvadSettings/AccessMethodRepositoryProtocol.swift +++ b/ios/MullvadSettings/AccessMethodRepositoryProtocol.swift @@ -7,7 +7,6 @@ // import Combine -import Foundation public protocol AccessMethodRepositoryDataSource { /// Publisher that propagates a snapshot of persistent store upon modifications. @@ -24,11 +23,7 @@ public protocol AccessMethodRepositoryDataSource { public protocol AccessMethodRepositoryProtocol: AccessMethodRepositoryDataSource { /// Add new access method. /// - Parameter method: persistent access method model. - func add(_ method: PersistentAccessMethod) - - /// Persist modified access method locating existing entry by id. - /// - Parameter method: persistent access method model. - func update(_ method: PersistentAccessMethod) + func save(_ method: PersistentAccessMethod) /// Delete access method by id. /// - Parameter id: an access method id. diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index b8ee9d98a5fb..6ea30d83c42b 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -58,7 +58,6 @@ 581DA2762A1E2FD10046ED47 /* WgKeyRotation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 581DA2742A1E283E0046ED47 /* WgKeyRotation.swift */; }; 581DFAEA2B176C51005D6D1C /* PersistentProxyConfiguration+ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 581DFAE92B176C51005D6D1C /* PersistentProxyConfiguration+ViewModel.swift */; }; 581DFAEC2B1770C1005D6D1C /* AccessMethodViewModel+NavigationItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 581DFAEB2B1770C1005D6D1C /* AccessMethodViewModel+NavigationItem.swift */; }; - 581DFAEE2B178DEA005D6D1C /* AccessMethodValidationError+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 581DFAED2B178DEA005D6D1C /* AccessMethodValidationError+Helpers.swift */; }; 581F23AD2A8CF92100788AB6 /* DefaultPathObserverFake.swift in Sources */ = {isa = PBXBuildFile; fileRef = 581F23AC2A8CF92100788AB6 /* DefaultPathObserverFake.swift */; }; 581F23AF2A8CF94D00788AB6 /* PingerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 581F23AE2A8CF94D00788AB6 /* PingerMock.swift */; }; 5820676426E771DB00655B05 /* TunnelManagerErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5820676326E771DB00655B05 /* TunnelManagerErrors.swift */; }; @@ -69,18 +68,13 @@ 582403822A827E1500163DE8 /* RelaySelectorWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 582403812A827E1500163DE8 /* RelaySelectorWrapper.swift */; }; 5826B6CB2ABD83E200B1CA13 /* PacketTunnelOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587C575226D2615F005EF767 /* PacketTunnelOptions.swift */; }; 5827B0902B0CAA0500CCBBA1 /* EditAccessMethodCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5827B08F2B0CAA0500CCBBA1 /* EditAccessMethodCoordinator.swift */; }; - 5827B0922B0CAB2800CCBBA1 /* ProxyConfigurationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5827B0912B0CAB2800CCBBA1 /* ProxyConfigurationViewController.swift */; }; - 5827B0942B0CACC700CCBBA1 /* ProxyConfigurationSectionIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5827B0932B0CACC700CCBBA1 /* ProxyConfigurationSectionIdentifier.swift */; }; - 5827B0962B0DB2C100CCBBA1 /* ProxyConfigurationItemIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5827B0952B0DB2C100CCBBA1 /* ProxyConfigurationItemIdentifier.swift */; }; - 5827B09B2B0DEEF800CCBBA1 /* AccessMethodActionSheetPresentationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5827B09A2B0DEEF800CCBBA1 /* AccessMethodActionSheetPresentationDelegate.swift */; }; - 5827B09D2B0DEF1000CCBBA1 /* AccessMethodActionSheetDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5827B09C2B0DEF1000CCBBA1 /* AccessMethodActionSheetDelegate.swift */; }; - 5827B09F2B0E05E600CCBBA1 /* AddAccessMethodInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5827B09E2B0E05E600CCBBA1 /* AddAccessMethodInteractor.swift */; }; + 5827B0922B0CAB2800CCBBA1 /* MethodSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5827B0912B0CAB2800CCBBA1 /* MethodSettingsViewController.swift */; }; + 5827B0962B0DB2C100CCBBA1 /* MethodSettingsItemIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5827B0952B0DB2C100CCBBA1 /* MethodSettingsItemIdentifier.swift */; }; 5827B0A42B0F38FD00CCBBA1 /* EditAccessMethodInteractorProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5827B0A32B0F38FD00CCBBA1 /* EditAccessMethodInteractorProtocol.swift */; }; 5827B0A62B0F39E900CCBBA1 /* EditAccessMethodInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5827B0A52B0F39E900CCBBA1 /* EditAccessMethodInteractor.swift */; }; 5827B0A82B0F49EF00CCBBA1 /* ProxyConfigurationInteractorProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5827B0A72B0F49EF00CCBBA1 /* ProxyConfigurationInteractorProtocol.swift */; }; 5827B0AA2B0F4C9100CCBBA1 /* EditAccessMethodViewControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5827B0A92B0F4C9100CCBBA1 /* EditAccessMethodViewControllerDelegate.swift */; }; - 5827B0AC2B0F4CA500CCBBA1 /* AddAccessMethodViewControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5827B0AB2B0F4CA500CCBBA1 /* AddAccessMethodViewControllerDelegate.swift */; }; - 5827B0AE2B0F4CBE00CCBBA1 /* ProxyConfigurationViewControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5827B0AD2B0F4CBE00CCBBA1 /* ProxyConfigurationViewControllerDelegate.swift */; }; + 5827B0AE2B0F4CBE00CCBBA1 /* MethodSettingsViewControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5827B0AD2B0F4CBE00CCBBA1 /* MethodSettingsViewControllerDelegate.swift */; }; 5827B0B02B0F4CCD00CCBBA1 /* ListAccessMethodViewControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5827B0AF2B0F4CCD00CCBBA1 /* ListAccessMethodViewControllerDelegate.swift */; }; 5827B0B92B14A1C700CCBBA1 /* MethodTestingStatusCellContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5827B0B82B14A1C700CCBBA1 /* MethodTestingStatusCellContentConfiguration.swift */; }; 5827B0BB2B14A28300CCBBA1 /* MethodTestingStatusCellContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5827B0BA2B14A28300CCBBA1 /* MethodTestingStatusCellContentView.swift */; }; @@ -149,7 +143,6 @@ 586A950C290125EE007BAF2B /* AlertPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58B9EB122488ED2100095626 /* AlertPresenter.swift */; }; 586A950E290125F3007BAF2B /* ProductsRequestOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5846226426E0D9630035F7C2 /* ProductsRequestOperation.swift */; }; 586A950F29012BEE007BAF2B /* AddressCacheTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06AC114028F841390037AF9A /* AddressCacheTracker.swift */; }; - 586C0D762B03934000E7CDD7 /* AddAccessMethodInteractorProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 586C0D752B03934000E7CDD7 /* AddAccessMethodInteractorProtocol.swift */; }; 586C0D782B039CC000E7CDD7 /* AccessMethodProtocolPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 586C0D772B039CC000E7CDD7 /* AccessMethodProtocolPicker.swift */; }; 586C0D7A2B039CE300E7CDD7 /* ShadowsocksCipherPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 586C0D792B039CE300E7CDD7 /* ShadowsocksCipherPicker.swift */; }; 586C0D7C2B03BDD100E7CDD7 /* AccessMethodViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 586C0D7B2B03BDD100E7CDD7 /* AccessMethodViewModel.swift */; }; @@ -158,16 +151,11 @@ 586C0D852B03D31E00E7CDD7 /* SocksSectionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 586C0D842B03D31E00E7CDD7 /* SocksSectionHandler.swift */; }; 586C0D872B03D39600E7CDD7 /* AccessMethodCellReuseIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 586C0D862B03D39600E7CDD7 /* AccessMethodCellReuseIdentifier.swift */; }; 586C0D892B03D5E000E7CDD7 /* TextCellContentConfiguration+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 586C0D882B03D5E000E7CDD7 /* TextCellContentConfiguration+Extensions.swift */; }; - 586C0D8B2B03D84400E7CDD7 /* AddAccessMethodItemIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 586C0D8A2B03D84400E7CDD7 /* AddAccessMethodItemIdentifier.swift */; }; - 586C0D8D2B03D86F00E7CDD7 /* AddAccessMethodSectionIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 586C0D8C2B03D86F00E7CDD7 /* AddAccessMethodSectionIdentifier.swift */; }; 586C0D8F2B03D88100E7CDD7 /* ProxyProtocolConfigurationItemIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 586C0D8E2B03D88100E7CDD7 /* ProxyProtocolConfigurationItemIdentifier.swift */; }; 586C0D912B03D8A400E7CDD7 /* AccessMethodHeaderFooterReuseIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 586C0D902B03D8A400E7CDD7 /* AccessMethodHeaderFooterReuseIdentifier.swift */; }; 586C0D932B03D90700E7CDD7 /* ShadowsocksItemIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 586C0D922B03D90700E7CDD7 /* ShadowsocksItemIdentifier.swift */; }; 586C0D952B03D92100E7CDD7 /* SocksItemIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 586C0D942B03D92100E7CDD7 /* SocksItemIdentifier.swift */; }; 586C0D992B04E20200E7CDD7 /* AccessMethodViewModel+Persistent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 586C0D982B04E20200E7CDD7 /* AccessMethodViewModel+Persistent.swift */; }; - 586C0D9B2B051E6900E7CDD7 /* AccessMethodActionSheetContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 586C0D9A2B051E6900E7CDD7 /* AccessMethodActionSheetContainerView.swift */; }; - 586C0D9D2B05F62B00E7CDD7 /* AccessMethodActionSheetContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 586C0D9C2B05F62B00E7CDD7 /* AccessMethodActionSheetContentView.swift */; }; - 586C0DA22B062B2900E7CDD7 /* AccessMethodActionSheetPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 586C0DA12B062B2900E7CDD7 /* AccessMethodActionSheetPresentation.swift */; }; 586C14582AC463BB00245C01 /* CommandChannelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 586C14572AC463BB00245C01 /* CommandChannelTests.swift */; }; 586C145A2AC4735F00245C01 /* PacketTunnelActor+Public.swift in Sources */ = {isa = PBXBuildFile; fileRef = 586C14592AC4735F00245C01 /* PacketTunnelActor+Public.swift */; }; 586E54FB27A2DF6D0029B88B /* SendTunnelProviderMessageOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 586E54FA27A2DF6D0029B88B /* SendTunnelProviderMessageOperation.swift */; }; @@ -415,16 +403,11 @@ 58EE2E3B272FF814003BFF93 /* SettingsDataSourceDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58EE2E39272FF814003BFF93 /* SettingsDataSourceDelegate.swift */; }; 58EF580B25D69D7A00AEBA94 /* ProblemReportSubmissionOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58EF580A25D69D7A00AEBA94 /* ProblemReportSubmissionOverlayView.swift */; }; 58EF581125D69DB400AEBA94 /* StatusImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58EF581025D69DB400AEBA94 /* StatusImageView.swift */; }; - 58EF874F2B16174A00C098B2 /* AccessMethodActionSheetPresentationConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58EF874E2B16174A00C098B2 /* AccessMethodActionSheetPresentationConfiguration.swift */; }; - 58EF87512B16176300C098B2 /* AccessMethodActionSheetContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58EF87502B16176300C098B2 /* AccessMethodActionSheetContentConfiguration.swift */; }; - 58EF87532B161D7C00C098B2 /* AccessMethodActionSheetConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58EF87522B161D7C00C098B2 /* AccessMethodActionSheetConfiguration.swift */; }; - 58EF87552B16282D00C098B2 /* AccessMethodActionSheetPresentationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58EF87542B16282D00C098B2 /* AccessMethodActionSheetPresentationView.swift */; }; 58EF87572B16330B00C098B2 /* ProxyConfigurationTester.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58EF87562B16330B00C098B2 /* ProxyConfigurationTester.swift */; }; 58EF875D2B1638BF00C098B2 /* ProxyConfigurationTesterProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58EF875C2B1638BF00C098B2 /* ProxyConfigurationTesterProtocol.swift */; }; 58EFC76A2AFAC3B800E9F4CB /* ListAccessMethodHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58EFC7692AFAC3B800E9F4CB /* ListAccessMethodHeaderView.swift */; }; 58EFC76E2AFB3BDA00E9F4CB /* ListAccessMethodCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58EFC76D2AFB3BDA00E9F4CB /* ListAccessMethodCoordinator.swift */; }; 58EFC7712AFB45E500E9F4CB /* SettingsChildCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58EFC7702AFB45E500E9F4CB /* SettingsChildCoordinator.swift */; }; - 58EFC7732AFB471500E9F4CB /* AddAccessMethodViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58EFC7722AFB471500E9F4CB /* AddAccessMethodViewController.swift */; }; 58EFC7752AFB4CEF00E9F4CB /* AboutViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58EFC7742AFB4CEF00E9F4CB /* AboutViewController.swift */; }; 58F0974E2A20C31100DA2DAD /* WireGuardKitTypes in Frameworks */ = {isa = PBXBuildFile; productRef = 58F0974D2A20C31100DA2DAD /* WireGuardKitTypes */; }; 58F0974F2A20C31100DA2DAD /* WireGuardKitTypes in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 58F0974D2A20C31100DA2DAD /* WireGuardKitTypes */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; @@ -505,8 +488,17 @@ 7A42DEC92A05164100B209BE /* SettingsInputCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A42DEC82A05164100B209BE /* SettingsInputCell.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 */; }; + 7A58699F2B50057100640D27 /* AccessMethodKind.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A58699D2B50057100640D27 /* AccessMethodKind.swift */; }; + 7A5869A22B502EA800640D27 /* MethodSettingsSectionIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A5869A12B502EA700640D27 /* MethodSettingsSectionIdentifier.swift */; }; + 7A5869A62B51405900640D27 /* MethodSettingsValidationErrorContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A5869A52B51405900640D27 /* MethodSettingsValidationErrorContentConfiguration.swift */; }; + 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 */; }; + 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 */; }; + 7A6000F92B6273A4001CF0D9 /* AccessMethodViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 586C0D7B2B03BDD100E7CDD7 /* AccessMethodViewModel.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 */; }; @@ -783,7 +775,6 @@ F0C6FA852A6A733700F521F0 /* InAppPurchaseInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0C6FA842A6A733700F521F0 /* InAppPurchaseInteractor.swift */; }; F0D7FF8F2B31DF5900E0FDE5 /* AccessMethodRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5827B0A02B0E064E00CCBBA1 /* AccessMethodRepository.swift */; }; F0D7FF902B31E00B00E0FDE5 /* AccessMethodKind.swift in Sources */ = {isa = PBXBuildFile; fileRef = 588D7ED72AF3A533005DF40A /* AccessMethodKind.swift */; }; - F0D7FF922B31E05D00E0FDE5 /* AccessMethodKind+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0D7FF912B31E05D00E0FDE5 /* AccessMethodKind+Extension.swift */; }; F0D8825B2B04F53600D3EF9A /* OutgoingConnectionData.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0D8825A2B04F53600D3EF9A /* OutgoingConnectionData.swift */; }; F0D8825C2B04F70E00D3EF9A /* OutgoingConnectionData.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0D8825A2B04F53600D3EF9A /* OutgoingConnectionData.swift */; }; F0DA87472A9CB9A2006044F1 /* AccountExpiryRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0DA87462A9CB9A2006044F1 /* AccountExpiryRow.swift */; }; @@ -1269,7 +1260,6 @@ 581DA2742A1E283E0046ED47 /* WgKeyRotation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WgKeyRotation.swift; sourceTree = ""; }; 581DFAE92B176C51005D6D1C /* PersistentProxyConfiguration+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PersistentProxyConfiguration+ViewModel.swift"; sourceTree = ""; }; 581DFAEB2B1770C1005D6D1C /* AccessMethodViewModel+NavigationItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccessMethodViewModel+NavigationItem.swift"; sourceTree = ""; }; - 581DFAED2B178DEA005D6D1C /* AccessMethodValidationError+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccessMethodValidationError+Helpers.swift"; sourceTree = ""; }; 581F23AC2A8CF92100788AB6 /* DefaultPathObserverFake.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultPathObserverFake.swift; sourceTree = ""; }; 581F23AE2A8CF94D00788AB6 /* PingerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PingerMock.swift; sourceTree = ""; }; 5820675A26E6576800655B05 /* RelayCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayCache.swift; sourceTree = ""; }; @@ -1286,19 +1276,14 @@ 5824037F2A827DF300163DE8 /* RelaySelectorProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelaySelectorProtocol.swift; sourceTree = ""; }; 582403812A827E1500163DE8 /* RelaySelectorWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelaySelectorWrapper.swift; sourceTree = ""; }; 5827B08F2B0CAA0500CCBBA1 /* EditAccessMethodCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditAccessMethodCoordinator.swift; sourceTree = ""; }; - 5827B0912B0CAB2800CCBBA1 /* ProxyConfigurationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProxyConfigurationViewController.swift; sourceTree = ""; }; - 5827B0932B0CACC700CCBBA1 /* ProxyConfigurationSectionIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ProxyConfigurationSectionIdentifier.swift; path = ../ProxyConfigurationSectionIdentifier.swift; sourceTree = ""; }; - 5827B0952B0DB2C100CCBBA1 /* ProxyConfigurationItemIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProxyConfigurationItemIdentifier.swift; sourceTree = ""; }; - 5827B09A2B0DEEF800CCBBA1 /* AccessMethodActionSheetPresentationDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessMethodActionSheetPresentationDelegate.swift; sourceTree = ""; }; - 5827B09C2B0DEF1000CCBBA1 /* AccessMethodActionSheetDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessMethodActionSheetDelegate.swift; sourceTree = ""; }; - 5827B09E2B0E05E600CCBBA1 /* AddAccessMethodInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddAccessMethodInteractor.swift; sourceTree = ""; }; + 5827B0912B0CAB2800CCBBA1 /* MethodSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MethodSettingsViewController.swift; sourceTree = ""; }; + 5827B0952B0DB2C100CCBBA1 /* MethodSettingsItemIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MethodSettingsItemIdentifier.swift; sourceTree = ""; }; 5827B0A02B0E064E00CCBBA1 /* AccessMethodRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessMethodRepository.swift; sourceTree = ""; }; 5827B0A32B0F38FD00CCBBA1 /* EditAccessMethodInteractorProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditAccessMethodInteractorProtocol.swift; sourceTree = ""; }; 5827B0A52B0F39E900CCBBA1 /* EditAccessMethodInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditAccessMethodInteractor.swift; sourceTree = ""; }; 5827B0A72B0F49EF00CCBBA1 /* ProxyConfigurationInteractorProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProxyConfigurationInteractorProtocol.swift; sourceTree = ""; }; 5827B0A92B0F4C9100CCBBA1 /* EditAccessMethodViewControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditAccessMethodViewControllerDelegate.swift; sourceTree = ""; }; - 5827B0AB2B0F4CA500CCBBA1 /* AddAccessMethodViewControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddAccessMethodViewControllerDelegate.swift; sourceTree = ""; }; - 5827B0AD2B0F4CBE00CCBBA1 /* ProxyConfigurationViewControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProxyConfigurationViewControllerDelegate.swift; sourceTree = ""; }; + 5827B0AD2B0F4CBE00CCBBA1 /* MethodSettingsViewControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MethodSettingsViewControllerDelegate.swift; sourceTree = ""; }; 5827B0AF2B0F4CCD00CCBBA1 /* ListAccessMethodViewControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListAccessMethodViewControllerDelegate.swift; sourceTree = ""; }; 5827B0B82B14A1C700CCBBA1 /* MethodTestingStatusCellContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MethodTestingStatusCellContentConfiguration.swift; sourceTree = ""; }; 5827B0BA2B14A28300CCBBA1 /* MethodTestingStatusCellContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MethodTestingStatusCellContentView.swift; sourceTree = ""; }; @@ -1381,7 +1366,6 @@ 58695A9F2A4ADA9200328DB3 /* TunnelObfuscationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelObfuscationTests.swift; sourceTree = ""; }; 586A95112901321B007BAF2B /* IPv6Endpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPv6Endpoint.swift; sourceTree = ""; }; 586A951329013235007BAF2B /* AnyIPEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyIPEndpoint.swift; sourceTree = ""; }; - 586C0D752B03934000E7CDD7 /* AddAccessMethodInteractorProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddAccessMethodInteractorProtocol.swift; sourceTree = ""; }; 586C0D772B039CC000E7CDD7 /* AccessMethodProtocolPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessMethodProtocolPicker.swift; sourceTree = ""; }; 586C0D792B039CE300E7CDD7 /* ShadowsocksCipherPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShadowsocksCipherPicker.swift; sourceTree = ""; }; 586C0D7B2B03BDD100E7CDD7 /* AccessMethodViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessMethodViewModel.swift; sourceTree = ""; }; @@ -1390,17 +1374,12 @@ 586C0D842B03D31E00E7CDD7 /* SocksSectionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocksSectionHandler.swift; sourceTree = ""; }; 586C0D862B03D39600E7CDD7 /* AccessMethodCellReuseIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessMethodCellReuseIdentifier.swift; sourceTree = ""; }; 586C0D882B03D5E000E7CDD7 /* TextCellContentConfiguration+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TextCellContentConfiguration+Extensions.swift"; sourceTree = ""; }; - 586C0D8A2B03D84400E7CDD7 /* AddAccessMethodItemIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddAccessMethodItemIdentifier.swift; sourceTree = ""; }; - 586C0D8C2B03D86F00E7CDD7 /* AddAccessMethodSectionIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddAccessMethodSectionIdentifier.swift; sourceTree = ""; }; 586C0D8E2B03D88100E7CDD7 /* ProxyProtocolConfigurationItemIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProxyProtocolConfigurationItemIdentifier.swift; sourceTree = ""; }; 586C0D902B03D8A400E7CDD7 /* AccessMethodHeaderFooterReuseIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessMethodHeaderFooterReuseIdentifier.swift; sourceTree = ""; }; 586C0D922B03D90700E7CDD7 /* ShadowsocksItemIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShadowsocksItemIdentifier.swift; sourceTree = ""; }; 586C0D942B03D92100E7CDD7 /* SocksItemIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocksItemIdentifier.swift; sourceTree = ""; }; 586C0D962B04E0AC00E7CDD7 /* PersistentAccessMethod.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistentAccessMethod.swift; sourceTree = ""; }; 586C0D982B04E20200E7CDD7 /* AccessMethodViewModel+Persistent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccessMethodViewModel+Persistent.swift"; sourceTree = ""; }; - 586C0D9A2B051E6900E7CDD7 /* AccessMethodActionSheetContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessMethodActionSheetContainerView.swift; sourceTree = ""; }; - 586C0D9C2B05F62B00E7CDD7 /* AccessMethodActionSheetContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessMethodActionSheetContentView.swift; sourceTree = ""; }; - 586C0DA12B062B2900E7CDD7 /* AccessMethodActionSheetPresentation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccessMethodActionSheetPresentation.swift; sourceTree = ""; }; 586C14572AC463BB00245C01 /* CommandChannelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandChannelTests.swift; sourceTree = ""; }; 586C14592AC4735F00245C01 /* PacketTunnelActor+Public.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PacketTunnelActor+Public.swift"; sourceTree = ""; }; 586E54FA27A2DF6D0029B88B /* SendTunnelProviderMessageOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendTunnelProviderMessageOperation.swift; sourceTree = ""; }; @@ -1599,17 +1578,12 @@ 58EE2E39272FF814003BFF93 /* SettingsDataSourceDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsDataSourceDelegate.swift; sourceTree = ""; }; 58EF580A25D69D7A00AEBA94 /* ProblemReportSubmissionOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProblemReportSubmissionOverlayView.swift; sourceTree = ""; }; 58EF581025D69DB400AEBA94 /* StatusImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusImageView.swift; sourceTree = ""; }; - 58EF874E2B16174A00C098B2 /* AccessMethodActionSheetPresentationConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessMethodActionSheetPresentationConfiguration.swift; sourceTree = ""; }; - 58EF87502B16176300C098B2 /* AccessMethodActionSheetContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessMethodActionSheetContentConfiguration.swift; sourceTree = ""; }; - 58EF87522B161D7C00C098B2 /* AccessMethodActionSheetConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessMethodActionSheetConfiguration.swift; sourceTree = ""; }; - 58EF87542B16282D00C098B2 /* AccessMethodActionSheetPresentationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessMethodActionSheetPresentationView.swift; sourceTree = ""; }; 58EF87562B16330B00C098B2 /* ProxyConfigurationTester.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProxyConfigurationTester.swift; sourceTree = ""; }; 58EF875A2B16385400C098B2 /* AccessMethodRepositoryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessMethodRepositoryProtocol.swift; sourceTree = ""; }; 58EF875C2B1638BF00C098B2 /* ProxyConfigurationTesterProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProxyConfigurationTesterProtocol.swift; sourceTree = ""; }; 58EFC7692AFAC3B800E9F4CB /* ListAccessMethodHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListAccessMethodHeaderView.swift; sourceTree = ""; }; 58EFC76D2AFB3BDA00E9F4CB /* ListAccessMethodCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListAccessMethodCoordinator.swift; sourceTree = ""; }; 58EFC7702AFB45E500E9F4CB /* SettingsChildCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsChildCoordinator.swift; sourceTree = ""; }; - 58EFC7722AFB471500E9F4CB /* AddAccessMethodViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddAccessMethodViewController.swift; sourceTree = ""; }; 58EFC7742AFB4CEF00E9F4CB /* AboutViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutViewController.swift; sourceTree = ""; }; 58F1311427E0B2AB007AC5BC /* Result+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Result+Extensions.swift"; sourceTree = ""; }; 58F19E34228C15BA00C7710B /* SpinnerActivityIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpinnerActivityIndicatorView.swift; sourceTree = ""; }; @@ -1675,8 +1649,16 @@ 7A42DEC82A05164100B209BE /* SettingsInputCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsInputCell.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 = ""; }; + 7A58699D2B50057100640D27 /* AccessMethodKind.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccessMethodKind.swift; sourceTree = ""; }; + 7A5869A12B502EA700640D27 /* MethodSettingsSectionIdentifier.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MethodSettingsSectionIdentifier.swift; sourceTree = ""; }; + 7A5869A52B51405900640D27 /* MethodSettingsValidationErrorContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MethodSettingsValidationErrorContentConfiguration.swift; sourceTree = ""; }; + 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 = ""; }; + 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 = ""; }; 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 = ""; }; @@ -1843,7 +1825,6 @@ F0C2AEFC2A0BB5CC00986207 /* NotificationProviderIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationProviderIdentifier.swift; sourceTree = ""; }; F0C6A8422AB08E54000777A8 /* RedeemVoucherViewConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedeemVoucherViewConfiguration.swift; sourceTree = ""; }; F0C6FA842A6A733700F521F0 /* InAppPurchaseInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppPurchaseInteractor.swift; sourceTree = ""; }; - F0D7FF912B31E05D00E0FDE5 /* AccessMethodKind+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccessMethodKind+Extension.swift"; sourceTree = ""; }; F0D8825A2B04F53600D3EF9A /* OutgoingConnectionData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutgoingConnectionData.swift; sourceTree = ""; }; F0DA87462A9CB9A2006044F1 /* AccountExpiryRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountExpiryRow.swift; sourceTree = ""; }; F0DA87482A9CBA9F006044F1 /* AccountDeviceRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDeviceRow.swift; sourceTree = ""; }; @@ -2112,29 +2093,6 @@ path = MullvadTypes; sourceTree = ""; }; - 581DFAEF2B187606005D6D1C /* Presentation */ = { - isa = PBXGroup; - children = ( - 586C0DA12B062B2900E7CDD7 /* AccessMethodActionSheetPresentation.swift */, - 58EF874E2B16174A00C098B2 /* AccessMethodActionSheetPresentationConfiguration.swift */, - 5827B09A2B0DEEF800CCBBA1 /* AccessMethodActionSheetPresentationDelegate.swift */, - 58EF87542B16282D00C098B2 /* AccessMethodActionSheetPresentationView.swift */, - ); - path = Presentation; - sourceTree = ""; - }; - 581DFAF02B187620005D6D1C /* Content view */ = { - isa = PBXGroup; - children = ( - 58EF87522B161D7C00C098B2 /* AccessMethodActionSheetConfiguration.swift */, - 586C0D9A2B051E6900E7CDD7 /* AccessMethodActionSheetContainerView.swift */, - 58EF87502B16176300C098B2 /* AccessMethodActionSheetContentConfiguration.swift */, - 586C0D9C2B05F62B00E7CDD7 /* AccessMethodActionSheetContentView.swift */, - 5827B09C2B0DEF1000CCBBA1 /* AccessMethodActionSheetDelegate.swift */, - ); - path = "Content view"; - sourceTree = ""; - }; 5823FA5726CE4A4100283BF8 /* TunnelManager */ = { isa = PBXGroup; children = ( @@ -2181,6 +2139,7 @@ children = ( 586C0D862B03D39600E7CDD7 /* AccessMethodCellReuseIdentifier.swift */, 586C0D902B03D8A400E7CDD7 /* AccessMethodHeaderFooterReuseIdentifier.swift */, + 7A6000F52B60092F001CF0D9 /* AccessMethodViewModelEditing.swift */, 586C0D8E2B03D88100E7CDD7 /* ProxyProtocolConfigurationItemIdentifier.swift */, 586C0D922B03D90700E7CDD7 /* ShadowsocksItemIdentifier.swift */, 586C0D822B03D2FF00E7CDD7 /* ShadowsocksSectionHandler.swift */, @@ -2190,15 +2149,21 @@ path = Common; sourceTree = ""; }; - 5827B0992B0DC0CA00CCBBA1 /* ProxyConfiguration */ = { + 5827B0992B0DC0CA00CCBBA1 /* MethodSettings */ = { isa = PBXGroup; children = ( - 5827B0952B0DB2C100CCBBA1 /* ProxyConfigurationItemIdentifier.swift */, - 5827B0932B0CACC700CCBBA1 /* ProxyConfigurationSectionIdentifier.swift */, - 5827B0912B0CAB2800CCBBA1 /* ProxyConfigurationViewController.swift */, - 5827B0AD2B0F4CBE00CCBBA1 /* ProxyConfigurationViewControllerDelegate.swift */, + 7A5869C42B5A899C00640D27 /* MethodSettingsCellConfiguration.swift */, + 7A5869C62B5A8E4C00640D27 /* MethodSettingsDataSourceConfiguration.swift */, + 5827B0952B0DB2C100CCBBA1 /* MethodSettingsItemIdentifier.swift */, + 7A5869A12B502EA700640D27 /* MethodSettingsSectionIdentifier.swift */, + 7A5869A52B51405900640D27 /* MethodSettingsValidationErrorContentConfiguration.swift */, + 7A5869A72B5140C200640D27 /* MethodSettingsValidationErrorContentView.swift */, + 5827B0912B0CAB2800CCBBA1 /* MethodSettingsViewController.swift */, + 5827B0AD2B0F4CBE00CCBBA1 /* MethodSettingsViewControllerDelegate.swift */, + 5827B0B82B14A1C700CCBBA1 /* MethodTestingStatusCellContentConfiguration.swift */, + 5827B0BA2B14A28300CCBBA1 /* MethodTestingStatusCellContentView.swift */, ); - path = ProxyConfiguration; + path = MethodSettings; sourceTree = ""; }; 5827B0A22B0E068800CCBBA1 /* AccessMethodRepository */ = { @@ -2429,6 +2394,7 @@ 7A0C0F622A979C4A0058EFCE /* Coordinator+Router.swift */, 5811DE4F239014550011EB53 /* NEVPNStatus+Debug.swift */, 58DFF7CF2B02560400F864E0 /* NSAttributedString+Markdown.swift */, + 5827B0C42B14D3E800CCBBA1 /* NSDiffableDataSourceSnapshot+Reconfigure.swift */, 587D9675288989DB00CD8F1C /* NSLayoutConstraint+Helpers.swift */, 5871FB9F254C26BF0051A0A4 /* NSRegularExpression+IPAddress.swift */, 06FAE67828F83CA50033DD93 /* RESTCreateApplePaymentResponse+Localization.swift */, @@ -2445,9 +2411,9 @@ 7ABE318C2A1CDD4500DF4963 /* UIFont+Weight.swift */, 58CEB2FA2AFD13E600E6E088 /* UIListContentConfiguration+Extensions.swift */, 58CEB2FC2AFD19D300E6E088 /* UITableView+ReuseIdentifier.swift */, + 7A58699A2B482FE200640D27 /* UITableViewCell+Disable.swift */, 7A21DACE2A30AA3700A787A9 /* UITextField+Appearance.swift */, 5878F4FF29CDA742003D4BE2 /* UIView+AutoLayoutBuilder.swift */, - 5827B0C42B14D3E800CCBBA1 /* NSDiffableDataSourceSnapshot+Reconfigure.swift */, ); path = Extensions; sourceTree = ""; @@ -2631,9 +2597,8 @@ 586C0D7D2B03BDE500E7CDD7 /* Models */ = { isa = PBXGroup; children = ( - F0D7FF912B31E05D00E0FDE5 /* AccessMethodKind+Extension.swift */, + 7A58699D2B50057100640D27 /* AccessMethodKind.swift */, 58FF9FF32B07C61B00E4C97D /* AccessMethodValidationError.swift */, - 581DFAED2B178DEA005D6D1C /* AccessMethodValidationError+Helpers.swift */, 586C0D7B2B03BDD100E7CDD7 /* AccessMethodViewModel.swift */, 581DFAEB2B1770C1005D6D1C /* AccessMethodViewModel+NavigationItem.swift */, 586C0D982B04E20200E7CDD7 /* AccessMethodViewModel+Persistent.swift */, @@ -2652,8 +2617,6 @@ 58FF9FE92B07653800E4C97D /* ButtonCellContentView.swift */, 58CEB3092AFD584700E6E088 /* CustomCellDisclosureHandling.swift */, 58CEB30B2AFD586600E6E088 /* DynamicBackgroundConfiguration.swift */, - 5827B0B82B14A1C700CCBBA1 /* MethodTestingStatusCellContentConfiguration.swift */, - 5827B0BA2B14A28300CCBBA1 /* MethodTestingStatusCellContentView.swift */, 58CEB3012AFD365600E6E088 /* SwitchCellContentConfiguration.swift */, 58CEB3032AFD36CE00E6E088 /* SwitchCellContentView.swift */, 58CEB2F42AFD0BB500E6E088 /* TextCellContentConfiguration.swift */, @@ -2663,15 +2626,6 @@ path = Cells; sourceTree = ""; }; - 586C0DA02B05FAF200E7CDD7 /* Sheet */ = { - isa = PBXGroup; - children = ( - 581DFAF02B187620005D6D1C /* Content view */, - 581DFAEF2B187606005D6D1C /* Presentation */, - ); - path = Sheet; - sourceTree = ""; - }; 587B75422669034500DEF7E9 /* Notification Providers */ = { isa = PBXGroup; children = ( @@ -3057,7 +3011,6 @@ 586C0D7D2B03BDE500E7CDD7 /* Models */, 5827B0972B0DBF3400CCBBA1 /* Pickers */, 5827B0BE2B14B37D00CCBBA1 /* Publisher+PreviousValue.swift */, - 586C0DA02B05FAF200E7CDD7 /* Sheet */, ); path = APIAccess; sourceTree = ""; @@ -3080,12 +3033,6 @@ isa = PBXGroup; children = ( 58CEB2E82AFBBA4A00E6E088 /* AddAccessMethodCoordinator.swift */, - 5827B09E2B0E05E600CCBBA1 /* AddAccessMethodInteractor.swift */, - 586C0D752B03934000E7CDD7 /* AddAccessMethodInteractorProtocol.swift */, - 586C0D8A2B03D84400E7CDD7 /* AddAccessMethodItemIdentifier.swift */, - 586C0D8C2B03D86F00E7CDD7 /* AddAccessMethodSectionIdentifier.swift */, - 58EFC7722AFB471500E9F4CB /* AddAccessMethodViewController.swift */, - 5827B0AB2B0F4CA500CCBBA1 /* AddAccessMethodViewControllerDelegate.swift */, ); path = Add; sourceTree = ""; @@ -3276,7 +3223,7 @@ 58FF9FE12B075BA600E4C97D /* EditAccessMethodSectionIdentifier.swift */, 58FF9FDF2B075ABC00E4C97D /* EditAccessMethodViewController.swift */, 5827B0A92B0F4C9100CCBBA1 /* EditAccessMethodViewControllerDelegate.swift */, - 5827B0992B0DC0CA00CCBBA1 /* ProxyConfiguration */, + 5827B0992B0DC0CA00CCBBA1 /* MethodSettings */, 5827B0A72B0F49EF00CCBBA1 /* ProxyConfigurationInteractorProtocol.swift */, ); path = Edit; @@ -4546,6 +4493,7 @@ A9A5F9F52ACB05160083449F /* RegisteredDeviceInAppNotificationProvider.swift in Sources */, F09D04B72AE941DA003D4F89 /* OutgoingConnectionProxyTests.swift in Sources */, F09D04B92AE95111003D4F89 /* OutgoingConnectionProxy.swift in Sources */, + 7A6000F92B6273A4001CF0D9 /* AccessMethodViewModel.swift in Sources */, A9A5F9F62ACB05160083449F /* TunnelStatusNotificationProvider.swift in Sources */, A9A5F9F72ACB05160083449F /* NotificationProviderProtocol.swift in Sources */, A9A5F9F82ACB05160083449F /* NotificationProviderIdentifier.swift in Sources */, @@ -4760,7 +4708,6 @@ 587B75412668FD7800DEF7E9 /* AccountExpirySystemNotificationProvider.swift in Sources */, 587988C728A2A01F00E3DF54 /* AccountDataThrottling.swift in Sources */, F04FBE612A8379EE009278D7 /* AppPreferences.swift in Sources */, - 586C0DA22B062B2900E7CDD7 /* AccessMethodActionSheetPresentation.swift in Sources */, 58DFF7D82B02774C00F864E0 /* ListItemPickerViewController.swift in Sources */, 5896CEF226972DEB00B0FAE8 /* AccountContentView.swift in Sources */, 7A3353932AAA089000F0A71C /* SimulatorTunnelInfo.swift in Sources */, @@ -4770,6 +4717,7 @@ 7AC8A3AF2ABC71D600DC4939 /* TermsOfServiceCoordinator.swift in Sources */, 58FF9FE22B075BA600E4C97D /* EditAccessMethodSectionIdentifier.swift in Sources */, F0C2AEFD2A0BB5CC00986207 /* NotificationProviderIdentifier.swift in Sources */, + 7A58699B2B482FE200640D27 /* UITableViewCell+Disable.swift in Sources */, 7A9CCCB72A96302800DD6A34 /* RevokedCoordinator.swift in Sources */, 587D96742886D87C00CD8F1C /* DeviceManagementContentView.swift in Sources */, 7A11DD0B2A9495D400098CD8 /* AppRoutes.swift in Sources */, @@ -4790,7 +4738,7 @@ 58BA693123EADA6A009DC256 /* SimulatorTunnelProvider.swift in Sources */, 7A9CCCB32A96302800DD6A34 /* WelcomeCoordinator.swift in Sources */, 587B753B2666467500DEF7E9 /* NotificationBannerView.swift in Sources */, - 5827B0922B0CAB2800CCBBA1 /* ProxyConfigurationViewController.swift in Sources */, + 5827B0922B0CAB2800CCBBA1 /* MethodSettingsViewController.swift in Sources */, 58B993B12608A34500BA7811 /* LoginContentView.swift in Sources */, 5878A27529093A310096FC88 /* StorePaymentEvent.swift in Sources */, 7A7AD28D29DC677800480EF1 /* FirstTimeLaunch.swift in Sources */, @@ -4823,11 +4771,9 @@ 588D7ED62AF3903F005DF40A /* ListAccessMethodViewController.swift in Sources */, 582BB1B1229569620055B6EF /* UINavigationBar+Appearance.swift in Sources */, 7A9FA1442A2E3FE5000B728D /* CheckableSettingsCell.swift in Sources */, - 586C0D8D2B03D86F00E7CDD7 /* AddAccessMethodSectionIdentifier.swift in Sources */, 58ACF6492655365700ACE4B7 /* PreferencesViewController.swift in Sources */, 7ABE318D2A1CDD4500DF4963 /* UIFont+Weight.swift in Sources */, 58C774BE29A7A249003A1A56 /* CustomNavigationController.swift in Sources */, - 5827B0942B0CACC700CCBBA1 /* ProxyConfigurationSectionIdentifier.swift in Sources */, E1FD0DF528AA7CE400299DB4 /* StatusActivityView.swift in Sources */, 7A2960FD2A964BB700389B82 /* AlertPresentation.swift in Sources */, 0697D6E728F01513007A9E99 /* TransportMonitor.swift in Sources */, @@ -4842,14 +4788,12 @@ 581DFAEC2B1770C1005D6D1C /* AccessMethodViewModel+NavigationItem.swift in Sources */, 58ACF64D26567A5000ACE4B7 /* CustomSwitch.swift in Sources */, F0DA874B2A9CBACB006044F1 /* AccountNumberRow.swift in Sources */, + 7A5869C72B5A8E4C00640D27 /* MethodSettingsDataSourceConfiguration.swift in Sources */, 58F2E14C276A61C000A79513 /* RotateKeyOperation.swift in Sources */, 5871FB96254ADE4E0051A0A4 /* ConsolidatedApplicationLog.swift in Sources */, - 5827B09D2B0DEF1000CCBBA1 /* AccessMethodActionSheetDelegate.swift in Sources */, F0E8E4C52A60499100ED26A3 /* AccountDeletionViewController.swift in Sources */, 7A9CCCC12A96302800DD6A34 /* AccountCoordinator.swift in Sources */, 58FEEB58260B662E00A621A8 /* AutomaticKeyboardResponder.swift in Sources */, - F0D7FF922B31E05D00E0FDE5 /* AccessMethodKind+Extension.swift in Sources */, - 58EF87532B161D7C00C098B2 /* AccessMethodActionSheetConfiguration.swift in Sources */, 5846227326E22A160035F7C2 /* StorePaymentObserver.swift in Sources */, F0E3618B2A4ADD2F00AEEF2B /* WelcomeContentView.swift in Sources */, 58F2E146276A2C9900A79513 /* StopTunnelOperation.swift in Sources */, @@ -4880,9 +4824,7 @@ 58F19E35228C15BA00C7710B /* SpinnerActivityIndicatorView.swift in Sources */, 5864859929A0D028006C5743 /* FormsheetPresentationController.swift in Sources */, 58CEB3022AFD365600E6E088 /* SwitchCellContentConfiguration.swift in Sources */, - 586C0D9D2B05F62B00E7CDD7 /* AccessMethodActionSheetContentView.swift in Sources */, 7A9CCCB52A96302800DD6A34 /* AddCreditSucceededCoordinator.swift in Sources */, - 58EF87552B16282D00C098B2 /* AccessMethodActionSheetPresentationView.swift in Sources */, 7A0C0F632A979C4A0058EFCE /* Coordinator+Router.swift in Sources */, 7A6F2FAB2AFD3097006D0856 /* CustomDNSCellFactory.swift in Sources */, 58A99ED3240014A0006599E9 /* TermsOfServiceViewController.swift in Sources */, @@ -4895,6 +4837,7 @@ 585CA70F25F8C44600B47C62 /* UIMetrics.swift in Sources */, E1187ABD289BBB850024E748 /* OutOfTimeContentView.swift in Sources */, 58CC40EF24A601900019D96E /* ObserverList.swift in Sources */, + 7A58699F2B50057100640D27 /* AccessMethodKind.swift in Sources */, 7A33538F2AA9FF1600F0A71C /* SimulatorTunnelProviderManager.swift in Sources */, 7A1A26432A2612AE00B978AA /* PaymentAlertPresenter.swift in Sources */, 7A9CCCBB2A96302800DD6A34 /* InAppPurchaseCoordinator.swift in Sources */, @@ -4902,17 +4845,16 @@ 5871FBA0254C26C00051A0A4 /* NSRegularExpression+IPAddress.swift in Sources */, F0EF50D52A949F8E0031E8DF /* ChangeLogViewModel.swift in Sources */, F0E8E4BB2A56C9F100ED26A3 /* WelcomeInteractor.swift in Sources */, + 7A6000F62B60092F001CF0D9 /* AccessMethodViewModelEditing.swift in Sources */, 5878A27729093A4F0096FC88 /* StorePaymentBlockObserver.swift in Sources */, 58EF87572B16330B00C098B2 /* ProxyConfigurationTester.swift in Sources */, 5827B0A62B0F39E900CCBBA1 /* EditAccessMethodInteractor.swift in Sources */, - 5827B0AE2B0F4CBE00CCBBA1 /* ProxyConfigurationViewControllerDelegate.swift in Sources */, - 5827B0962B0DB2C100CCBBA1 /* ProxyConfigurationItemIdentifier.swift in Sources */, - 586C0D8B2B03D84400E7CDD7 /* AddAccessMethodItemIdentifier.swift in Sources */, + 5827B0AE2B0F4CBE00CCBBA1 /* MethodSettingsViewControllerDelegate.swift in Sources */, + 5827B0962B0DB2C100CCBBA1 /* MethodSettingsItemIdentifier.swift in Sources */, 5868585524054096000B8131 /* CustomButton.swift in Sources */, 58E25F812837BBBB002CFB2C /* SceneDelegate.swift in Sources */, 7A1A26492A29D48A00B978AA /* RelayFilterCellFactory.swift in Sources */, 5867771629097C5B006F721F /* ProductState.swift in Sources */, - 586C0D762B03934000E7CDD7 /* AddAccessMethodInteractorProtocol.swift in Sources */, 58C76A082A33850E00100D75 /* ApplicationTarget.swift in Sources */, 58CEB3042AFD36CE00E6E088 /* SwitchCellContentView.swift in Sources */, F07BF2622A26279100042943 /* RedeemVoucherOperation.swift in Sources */, @@ -4932,7 +4874,6 @@ 58CEB2F32AFD0BA100E6E088 /* TextCellContentView.swift in Sources */, 586C0D7C2B03BDD100E7CDD7 /* AccessMethodViewModel.swift in Sources */, 587A01FC23F1F0BE00B68763 /* SimulatorTunnelProviderHost.swift in Sources */, - 58EF874F2B16174A00C098B2 /* AccessMethodActionSheetPresentationConfiguration.swift in Sources */, 7A6F2FA72AFBB9AE006D0856 /* AccountExpiry.swift in Sources */, 5819C2172729595500D6EC38 /* SettingsAddDNSEntryCell.swift in Sources */, 7A1A26452A29CEF700B978AA /* RelayFilterViewController.swift in Sources */, @@ -4943,6 +4884,7 @@ 5878A26F2907E7E00096FC88 /* ProblemReportInteractor.swift in Sources */, 7A3353912AAA014400F0A71C /* SimulatorVPNConnection.swift in Sources */, F028A56A2A34D4E700C0CAA3 /* RedeemVoucherViewController.swift in Sources */, + 7A5869C52B5A899C00640D27 /* MethodSettingsCellConfiguration.swift in Sources */, 58E11188292FA11F009FCA84 /* SettingsMigrationUIHandler.swift in Sources */, 58CAFA002983FF0200BE19F7 /* LoginInteractor.swift in Sources */, 5807E2C02432038B00F5FF30 /* String+Split.swift in Sources */, @@ -4956,7 +4898,6 @@ 5878F50029CDA742003D4BE2 /* UIView+AutoLayoutBuilder.swift in Sources */, 583FE01029C0F532006E85F9 /* CustomSplitViewController.swift in Sources */, 7A6F2FA92AFD0842006D0856 /* CustomDNSDataSource.swift in Sources */, - 5827B0AC2B0F4CA500CCBBA1 /* AddAccessMethodViewControllerDelegate.swift in Sources */, 58EF580B25D69D7A00AEBA94 /* ProblemReportSubmissionOverlayView.swift in Sources */, 5892A45E265FABFF00890742 /* EmptyTableViewHeaderFooterView.swift in Sources */, 580909D32876D09A0078138D /* RevokedDeviceViewController.swift in Sources */, @@ -4972,9 +4913,7 @@ 5827B0A82B0F49EF00CCBBA1 /* ProxyConfigurationInteractorProtocol.swift in Sources */, 586C0D7A2B039CE300E7CDD7 /* ShadowsocksCipherPicker.swift in Sources */, 58EFC76A2AFAC3B800E9F4CB /* ListAccessMethodHeaderView.swift in Sources */, - 58EFC7732AFB471500E9F4CB /* AddAccessMethodViewController.swift in Sources */, 58B93A1326C3F13600A55733 /* TunnelState.swift in Sources */, - 5827B09F2B0E05E600CCBBA1 /* AddAccessMethodInteractor.swift in Sources */, 58FF9FEC2B07A7CB00E4C97D /* NSDirectionalEdgeInsets+Helpers.swift in Sources */, 586C0D832B03D2FF00E7CDD7 /* ShadowsocksSectionHandler.swift in Sources */, 58B26E262943522400D5980C /* NotificationProvider.swift in Sources */, @@ -4992,12 +4931,10 @@ 58FF9FEA2B07653800E4C97D /* ButtonCellContentView.swift in Sources */, 7A5869AD2B5552E200640D27 /* IPOverrideViewController.swift in Sources */, F0E8E4C12A602CCB00ED26A3 /* AccountDeletionContentView.swift in Sources */, - 58EF87512B16176300C098B2 /* AccessMethodActionSheetContentConfiguration.swift in Sources */, 58B26E1E2943514300D5980C /* InAppNotificationDescriptor.swift in Sources */, 58421032282E42B000F24E46 /* UpdateDeviceDataOperation.swift in Sources */, 586C0D992B04E20200E7CDD7 /* AccessMethodViewModel+Persistent.swift in Sources */, 5878A27D2909657C0096FC88 /* RevokedDeviceInteractor.swift in Sources */, - 5827B09B2B0DEEF800CCBBA1 /* AccessMethodActionSheetPresentationDelegate.swift in Sources */, F0E8E4C32A602E0D00ED26A3 /* AccountDeletionViewModel.swift in Sources */, 586C0D782B039CC000E7CDD7 /* AccessMethodProtocolPicker.swift in Sources */, 58677710290975E9006F721F /* SettingsInteractorFactory.swift in Sources */, @@ -5020,7 +4957,6 @@ 7AF10EB22ADE859200C090B9 /* AlertViewController.swift in Sources */, 587D9676288989DB00CD8F1C /* NSLayoutConstraint+Helpers.swift in Sources */, F028A56C2A34D8E600C0CAA3 /* AddCreditSucceededViewController.swift in Sources */, - 581DFAEE2B178DEA005D6D1C /* AccessMethodValidationError+Helpers.swift in Sources */, 58293FAE2510CA58005D0BB5 /* ProblemReportViewController.swift in Sources */, 58B9EB152489139B00095626 /* RESTError+Display.swift in Sources */, 587B753F2668E5A700DEF7E9 /* NotificationContainerView.swift in Sources */, @@ -5049,12 +4985,15 @@ 5827B0BF2B14B37D00CCBBA1 /* Publisher+PreviousValue.swift in Sources */, 7A9CCCB62A96302800DD6A34 /* OutOfTimeCoordinator.swift in Sources */, 5827B0AA2B0F4C9100CCBBA1 /* EditAccessMethodViewControllerDelegate.swift in Sources */, + 7A5869A82B5140C200640D27 /* MethodSettingsValidationErrorContentView.swift in Sources */, + 7A5869A22B502EA800640D27 /* MethodSettingsSectionIdentifier.swift in Sources */, 586C0D812B03CA8400E7CDD7 /* CurrentValueSubject+UIActionBindings.swift in Sources */, 581DFAEA2B176C51005D6D1C /* PersistentProxyConfiguration+ViewModel.swift in Sources */, 58FD5BF024238EB300112C88 /* SKProduct+Formatting.swift in Sources */, 58B43C1925F77DB60002C8C3 /* TunnelControlView.swift in Sources */, F09A297B2A9F8A9B00EA3B6F /* LogoutDialogueView.swift in Sources */, 58CEB2FB2AFD13E600E6E088 /* UIListContentConfiguration+Extensions.swift in Sources */, + 7A5869A62B51405900640D27 /* MethodSettingsValidationErrorContentConfiguration.swift in Sources */, 5811DE50239014550011EB53 /* NEVPNStatus+Debug.swift in Sources */, 7A21DACF2A30AA3700A787A9 /* UITextField+Appearance.swift in Sources */, 585B1FF02AB09F97008AD470 /* VPNConnectionProtocol.swift in Sources */, @@ -5066,7 +5005,6 @@ 5827B0C52B14D3E800CCBBA1 /* NSDiffableDataSourceSnapshot+Reconfigure.swift in Sources */, 58A8EE5E2976DB00009C0F8D /* StorePaymentManagerError+Display.swift in Sources */, 58A8EE5A2976BFBB009C0F8D /* SKError+Localized.swift in Sources */, - 586C0D9B2B051E6900E7CDD7 /* AccessMethodActionSheetContainerView.swift in Sources */, 58EFC76E2AFB3BDA00E9F4CB /* ListAccessMethodCoordinator.swift in Sources */, 5827B0B92B14A1C700CCBBA1 /* MethodTestingStatusCellContentConfiguration.swift in Sources */, 7A42DEC92A05164100B209BE /* SettingsInputCell.swift in Sources */, diff --git a/ios/MullvadVPN/AppDelegate.swift b/ios/MullvadVPN/AppDelegate.swift index fba6e13391fd..8d8b9ab0f43e 100644 --- a/ios/MullvadVPN/AppDelegate.swift +++ b/ios/MullvadVPN/AppDelegate.swift @@ -48,6 +48,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD // MARK: - Application lifecycle + // swiftlint:disable:function_body_length func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? @@ -68,11 +69,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD let relayCache = RelayCache(cacheDirectory: containerURL) relayCacheTracker = RelayCacheTracker(relayCache: relayCache, application: application, apiProxy: apiProxy) - addressCacheTracker = AddressCacheTracker( - application: application, - apiProxy: apiProxy, - store: addressCache - ) + addressCacheTracker = AddressCacheTracker(application: application, apiProxy: apiProxy, store: addressCache) tunnelStore = TunnelStore(application: application) tunnelManager = createTunnelManager(application: application) diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/AboutViewController.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/AboutViewController.swift index 60a35bc95b04..7eca0d3b9747 100644 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/AboutViewController.swift +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/AboutViewController.swift @@ -8,13 +8,19 @@ import UIKit -/// View controller used for presenting a detailed information on some topic using markdown in a scrollable text view. +/// View controller used for presenting a detailed information on some topic using a scrollable stack view. class AboutViewController: UIViewController { - private let textView = UITextView() - private let markdown: String + private let scrollView = UIScrollView() + private let contentView = UIStackView() + private let header: String? + private let preamble: String? + private let body: [String] + + init(header: String?, preamble: String?, body: [String]) { + self.header = header + self.preamble = preamble + self.body = body - init(markdown: String) { - self.markdown = markdown super.init(nibName: nil, bundle: nil) } @@ -25,20 +31,62 @@ class AboutViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - let paragraphStyle = NSMutableParagraphStyle() - paragraphStyle.paragraphSpacing = 16 + view.backgroundColor = .secondaryColor + navigationController?.navigationBar.configureCustomAppeareance() + + setUpContentView() + + scrollView.addConstrainedSubviews([contentView]) { + contentView.pinEdgesToSuperview() + contentView.widthAnchor.constraint(equalTo: scrollView.widthAnchor) + } + + view.addConstrainedSubviews([scrollView]) { + scrollView.pinEdgesToSuperview() + } + } + + private func setUpContentView() { + contentView.axis = .vertical + contentView.spacing = 15 + contentView.layoutMargins = UIMetrics.contentInsets + contentView.isLayoutMarginsRelativeArrangement = true + + if let header { + let label = UILabel() + + label.text = header + label.font = .systemFont(ofSize: 28, weight: .bold) + label.textColor = .white + label.numberOfLines = 0 + label.textAlignment = .center + + contentView.addArrangedSubview(label) + contentView.setCustomSpacing(32, after: label) + } + + if let preamble { + let label = UILabel() + + label.text = preamble + label.font = .systemFont(ofSize: 18) + label.textColor = .white + label.numberOfLines = 0 + label.textAlignment = .center + + contentView.addArrangedSubview(label) + contentView.setCustomSpacing(24, after: label) + } - let stylingOptions = MarkdownStylingOptions( - font: .systemFont(ofSize: 17), - paragraphStyle: paragraphStyle - ) + for text in body { + let label = UILabel() - textView.attributedText = NSAttributedString(markdownString: markdown, options: stylingOptions) - textView.textContainerInset = UIMetrics.contentInsets - textView.isEditable = false + label.text = text + label.font = .systemFont(ofSize: 15) + label.textColor = .white + label.numberOfLines = 0 - view.addConstrainedSubviews([textView]) { - textView.pinEdgesToSuperview() + contentView.addArrangedSubview(label) } } } diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Add/AddAccessMethodCoordinator.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Add/AddAccessMethodCoordinator.swift index 116c1fbb8f96..8c81f79f6e04 100644 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Add/AddAccessMethodCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Add/AddAccessMethodCoordinator.swift @@ -11,52 +11,81 @@ import MullvadSettings import Routing import UIKit -class AddAccessMethodCoordinator: Coordinator, Presentable { +class AddAccessMethodCoordinator: Coordinator, Presentable, Presenting { private let subject: CurrentValueSubject = .init(AccessMethodViewModel()) + let navigationController: UINavigationController + let accessMethodRepository: AccessMethodRepositoryProtocol + let proxyConfigurationTester: ProxyConfigurationTesterProtocol + var presentedViewController: UIViewController { navigationController } - let navigationController: UINavigationController - let accessMethodRepo: AccessMethodRepositoryProtocol - let proxyConfigurationTester: ProxyConfigurationTesterProtocol - init( navigationController: UINavigationController, accessMethodRepo: AccessMethodRepositoryProtocol, proxyConfigurationTester: ProxyConfigurationTesterProtocol ) { self.navigationController = navigationController - self.accessMethodRepo = accessMethodRepo + self.accessMethodRepository = accessMethodRepo self.proxyConfigurationTester = proxyConfigurationTester } func start() { - let controller = AddAccessMethodViewController( + let controller = MethodSettingsViewController( subject: subject, - interactor: AddAccessMethodInteractor( + interactor: EditAccessMethodInteractor( subject: subject, - repo: accessMethodRepo, + repository: accessMethodRepository, proxyConfigurationTester: proxyConfigurationTester - ) + ), + alertPresenter: AlertPresenter(context: self) ) + + setUpControllerNavigationItem(controller) controller.delegate = self navigationController.pushViewController(controller, animated: false) } -} -extension AddAccessMethodCoordinator: AddAccessMethodViewControllerDelegate { - func controllerDidAdd(_ controller: AddAccessMethodViewController) { - dismiss(animated: true) + private func setUpControllerNavigationItem(_ controller: MethodSettingsViewController) { + controller.navigationItem.prompt = NSLocalizedString( + "METHOD_SETTINGS_NAVIGATION_ADD_PROMPT", + tableName: "APIAccess", + value: "The app will test the method before saving.", + comment: "" + ) + + controller.navigationItem.title = NSLocalizedString( + "METHOD_SETTINGS_NAVIGATION_ADD_TITLE", + tableName: "APIAccess", + value: "Add access method", + comment: "" + ) + + controller.saveBarButton.title = NSLocalizedString( + "METHOD_SETTINGS_NAVIGATION_ADD_BUTTON", + tableName: "APIAccess", + value: "Add", + comment: "" + ) + + controller.navigationItem.leftBarButtonItem = UIBarButtonItem( + systemItem: .cancel, + primaryAction: UIAction(handler: { [weak self] _ in + self?.dismiss(animated: true) + }) + ) } +} - func controllerDidCancel(_ controller: AddAccessMethodViewController) { +extension AddAccessMethodCoordinator: MethodSettingsViewControllerDelegate { + func accessMethodDidSave(_ accessMethod: PersistentAccessMethod) { dismiss(animated: true) } - func controllerShouldShowProtocolPicker(_ controller: AddAccessMethodViewController) { + func controllerShouldShowProtocolPicker(_ controller: MethodSettingsViewController) { let picker = AccessMethodProtocolPicker(navigationController: navigationController) picker.present(currentValue: subject.value.method) { [weak self] newMethod in @@ -64,7 +93,7 @@ extension AddAccessMethodCoordinator: AddAccessMethodViewControllerDelegate { } } - func controllerShouldShowShadowsocksCipherPicker(_ controller: AddAccessMethodViewController) { + func controllerShouldShowShadowsocksCipherPicker(_ controller: MethodSettingsViewController) { let picker = ShadowsocksCipherPicker(navigationController: navigationController) picker.present(currentValue: subject.value.shadowsocks.cipher) { [weak self] selectedCipher in diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Add/AddAccessMethodInteractor.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Add/AddAccessMethodInteractor.swift deleted file mode 100644 index 919bc72de775..000000000000 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Add/AddAccessMethodInteractor.swift +++ /dev/null @@ -1,43 +0,0 @@ -// -// AddAccessMethodInteractor.swift -// MullvadVPN -// -// Created by pronebird on 22/11/2023. -// Copyright © 2023 Mullvad VPN AB. All rights reserved. -// - -import Combine -import Foundation -import MullvadSettings - -struct AddAccessMethodInteractor: AddAccessMethodInteractorProtocol { - let subject: CurrentValueSubject - let repo: AccessMethodRepositoryProtocol - let proxyConfigurationTester: ProxyConfigurationTesterProtocol - - func addMethod() { - guard let persistentMethod = try? subject.value.intoPersistentAccessMethod() else { return } - repo.add(persistentMethod) - } - - func startProxyConfigurationTest(_ completion: ((Bool) -> Void)?) { - guard let config = try? subject.value.intoPersistentProxyConfiguration() else { return } - - let subject = subject - subject.value.testingStatus = .inProgress - - proxyConfigurationTester.start(configuration: config) { error in - let succeeded = error == nil - - subject.value.testingStatus = succeeded ? .succeeded : .failed - - completion?(succeeded) - } - } - - func cancelProxyConfigurationTest() { - subject.value.testingStatus = .initial - - proxyConfigurationTester.cancel() - } -} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Add/AddAccessMethodInteractorProtocol.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Add/AddAccessMethodInteractorProtocol.swift deleted file mode 100644 index 62e7e6fe017c..000000000000 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Add/AddAccessMethodInteractorProtocol.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// AddAccessMethodInteractorProtocol.swift -// MullvadVPN -// -// Created by pronebird on 14/11/2023. -// Copyright © 2023 Mullvad VPN AB. All rights reserved. -// - -import UIKit - -/// The type implementing the interface for persisting the underlying access method view model in the new entry context. -protocol AddAccessMethodInteractorProtocol: ProxyConfigurationInteractorProtocol { - /// Add new access method to the persistent store. - /// - /// - Calling this method multiple times does nothing as the entry with the same identifier cannot be added more than once. - /// - View controllers should only call this method for valid view models, as this method will do nothing if the view model fails validation. - func addMethod() -} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Add/AddAccessMethodItemIdentifier.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Add/AddAccessMethodItemIdentifier.swift deleted file mode 100644 index 4eb34385e552..000000000000 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Add/AddAccessMethodItemIdentifier.swift +++ /dev/null @@ -1,61 +0,0 @@ -// -// AddAccessMethodItemIdentifier.swift -// MullvadVPN -// -// Created by pronebird on 14/11/2023. -// Copyright © 2023 Mullvad VPN AB. All rights reserved. -// - -import UIKit - -enum AddAccessMethodItemIdentifier: Hashable { - case name - case `protocol` - case proxyConfiguration(ProxyProtocolConfigurationItemIdentifier) - - /// Returns all shadowsocks items wrapped into `ProxyConfigurationItemIdentifier.proxyConfiguration`. - static var allShadowsocksItems: [AddAccessMethodItemIdentifier] { - ShadowsocksItemIdentifier.allCases.map { .proxyConfiguration(.shadowsocks($0)) } - } - - /// Returns all socks items wrapped into `ProxyConfigurationItemIdentifier.proxyConfiguration`. - static func allSocksItems(authenticate: Bool) -> [AddAccessMethodItemIdentifier] { - SocksItemIdentifier.allCases(authenticate: authenticate).map { .proxyConfiguration(.socks($0)) } - } - - /// Cell identifier for the item identifier. - var cellIdentifier: AccessMethodCellReuseIdentifier { - switch self { - case .name: - .textInput - case .protocol: - .textWithDisclosure - case let .proxyConfiguration(item): - item.cellIdentifier - } - } - - /// Whether cell representing the item should be selectable. - var isSelectable: Bool { - switch self { - case .name: - false - case .protocol: - true - case let .proxyConfiguration(item): - item.isSelectable - } - } - - /// The text label for the corresponding cell. - var text: String? { - switch self { - case .name: - NSLocalizedString("NAME", tableName: "APIAccess", value: "Name", comment: "") - case .protocol: - NSLocalizedString("TYPE", tableName: "APIAccess", value: "Type", comment: "") - case .proxyConfiguration: - nil - } - } -} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Add/AddAccessMethodSectionIdentifier.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Add/AddAccessMethodSectionIdentifier.swift deleted file mode 100644 index 9bd0fe9953d3..000000000000 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Add/AddAccessMethodSectionIdentifier.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// AddAccessMethodSectionIdentifier.swift -// MullvadVPN -// -// Created by pronebird on 14/11/2023. -// Copyright © 2023 Mullvad VPN AB. All rights reserved. -// - -import Foundation - -enum AddAccessMethodSectionIdentifier: Hashable { - case name - case `protocol` - case proxyConfiguration - - /// The section name. - var sectionName: String? { - switch self { - case .name: - nil - case .protocol: - NSLocalizedString( - "PROTOCOL_SECTION_TITLE", - tableName: "APIAccess", - value: "Protocol", - comment: "" - ) - case .proxyConfiguration: - NSLocalizedString( - "HOST_CONFIG_SECTION_TITLE", - tableName: "APIAccess", - value: "Host configuration", - comment: "" - ) - } - } -} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Add/AddAccessMethodViewController.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Add/AddAccessMethodViewController.swift deleted file mode 100644 index 8f8dec50b80e..000000000000 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Add/AddAccessMethodViewController.swift +++ /dev/null @@ -1,374 +0,0 @@ -// -// AddAccessMethodViewController.swift -// MullvadVPN -// -// Created by pronebird on 08/11/2023. -// Copyright © 2023 Mullvad VPN AB. All rights reserved. -// - -import Combine -import UIKit - -/// The view controller providing the interface for adding new access method. -class AddAccessMethodViewController: UIViewController, UITableViewDelegate { - private let interactor: AddAccessMethodInteractorProtocol - private var validationError: AccessMethodValidationError? - private let viewModelSubject: CurrentValueSubject - private var cancellables = Set() - private var dataSource: UITableViewDiffableDataSource< - AddAccessMethodSectionIdentifier, - AddAccessMethodItemIdentifier - >? - private lazy var cancelBarButton: UIBarButtonItem = { - UIBarButtonItem( - systemItem: .cancel, - primaryAction: UIAction(handler: { [weak self] _ in - self?.onCancel() - }) - ) - }() - - private lazy var addBarButton: UIBarButtonItem = { - let barButtonItem = UIBarButtonItem( - title: NSLocalizedString("ADD_NAVIGATION_BUTTON", tableName: "APIAccess", value: "Add", comment: ""), - primaryAction: UIAction { [weak self] _ in - self?.onAdd() - } - ) - barButtonItem.style = .done - return barButtonItem - }() - - private lazy var sheetPresentation: AccessMethodActionSheetPresentation = { - let sheetPresentation = AccessMethodActionSheetPresentation() - sheetPresentation.delegate = self - return sheetPresentation - }() - - private let contentController = UITableViewController(style: .insetGrouped) - private var tableView: UITableView { contentController.tableView } - - weak var delegate: AddAccessMethodViewControllerDelegate? - - init(subject: CurrentValueSubject, interactor: AddAccessMethodInteractorProtocol) { - self.viewModelSubject = subject - 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.directionalLayoutMargins = UIMetrics.contentLayoutMargins - view.backgroundColor = .secondaryColor - - configureTableView() - configureNavigationItem() - configureDataSource() - } - - // MARK: - UITableViewDelegate - - func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - guard let sectionIdentifier = dataSource?.snapshot().sectionIdentifiers[section] else { return nil } - - guard let headerView = tableView - .dequeueReusableView(withIdentifier: AccessMethodHeaderFooterReuseIdentifier.primary) - else { return nil } - - var contentConfiguration = UIListContentConfiguration.mullvadGroupedHeader() - contentConfiguration.text = sectionIdentifier.sectionName - - headerView.contentConfiguration = contentConfiguration - - return headerView - } - - func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { - guard let itemIdentifier = dataSource?.itemIdentifier(for: indexPath), - itemIdentifier.isSelectable else { return nil } - - return indexPath - } - - func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool { - guard let itemIdentifier = dataSource?.itemIdentifier(for: indexPath) else { return false } - - return itemIdentifier.isSelectable - } - - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - let itemIdentifier = dataSource?.itemIdentifier(for: indexPath) - - switch itemIdentifier { - case .protocol: - showProtocolSelector() - case .proxyConfiguration(.shadowsocks(.cipher)): - showShadowsocksCipher() - default: - break - } - } - - // MARK: - Pickers handling - - private func showProtocolSelector() { - view.endEditing(false) - delegate?.controllerShouldShowProtocolPicker(self) - } - - private func showShadowsocksCipher() { - view.endEditing(false) - delegate?.controllerShouldShowShadowsocksCipherPicker(self) - } - - // MARK: - Cell configuration - - private func dequeueCell(at indexPath: IndexPath, for itemIdentifier: AddAccessMethodItemIdentifier) - -> UITableViewCell { - let cell = tableView.dequeueReusableView(withIdentifier: itemIdentifier.cellIdentifier, for: indexPath) - - if let cell = cell as? DynamicBackgroundConfiguration { - cell.setAutoAdaptingBackgroundConfiguration(.mullvadListGroupedCell(), selectionType: .dimmed) - } - - switch itemIdentifier { - case .name: - configureName(cell, itemIdentifier: itemIdentifier) - case .protocol: - configureProtocol(cell, itemIdentifier: itemIdentifier) - case let .proxyConfiguration(proxyItemIdentifier): - configureProxy(cell, itemIdentifier: proxyItemIdentifier) - } - - return cell - } - - private func configureProxy(_ cell: UITableViewCell, itemIdentifier: ProxyProtocolConfigurationItemIdentifier) { - switch itemIdentifier { - case let .socks(socksItemIdentifier): - let section = SocksSectionHandler(tableStyle: tableView.style, subject: viewModelSubject) - section.configure(cell, itemIdentifier: socksItemIdentifier) - - case let .shadowsocks(shadowsocksItemIdentifier): - let section = ShadowsocksSectionHandler(tableStyle: tableView.style, subject: viewModelSubject) - section.configure(cell, itemIdentifier: shadowsocksItemIdentifier) - } - } - - private func configureName(_ cell: UITableViewCell, itemIdentifier: AddAccessMethodItemIdentifier) { - var contentConfiguration = TextCellContentConfiguration() - contentConfiguration.text = itemIdentifier.text - contentConfiguration.setPlaceholder(type: .optional) - contentConfiguration.textFieldProperties = .withAutoResignAndDoneReturnKey() - contentConfiguration.inputText = viewModelSubject.value.name - contentConfiguration.editingEvents.onChange = viewModelSubject.bindTextAction(to: \.name) - cell.contentConfiguration = contentConfiguration - } - - private func configureProtocol(_ cell: UITableViewCell, itemIdentifier: AddAccessMethodItemIdentifier) { - var contentConfiguration = UIListContentConfiguration.mullvadValueCell(tableStyle: tableView.style) - contentConfiguration.text = itemIdentifier.text - contentConfiguration.secondaryText = viewModelSubject.value.method.localizedDescription - cell.contentConfiguration = contentConfiguration - - if let cell = cell as? CustomCellDisclosureHandling { - cell.disclosureType = .chevron - } - } - - // MARK: - Data source handling - - private func configureDataSource() { - tableView.registerReusableViews(from: AccessMethodCellReuseIdentifier.self) - tableView.registerReusableViews(from: AccessMethodHeaderFooterReuseIdentifier.self) - - dataSource = UITableViewDiffableDataSource( - tableView: tableView, - cellProvider: { [weak self] _, indexPath, itemIdentifier in - self?.dequeueCell(at: indexPath, for: itemIdentifier) - } - ) - - viewModelSubject.withPreviousValue().sink { [weak self] previousValue, newValue in - self?.viewModelDidChange(previousValue: previousValue, newValue: newValue) - } - .store(in: &cancellables) - } - - private func viewModelDidChange(previousValue: AccessMethodViewModel?, newValue: AccessMethodViewModel) { - let animated = view.window != nil - let previousValidationError = validationError - - validate() - updateBarButtons(newValue: newValue) - updateSheet(previousValue: previousValue, newValue: newValue, animated: animated) - updateModalPresentation(newValue: newValue) - updateDataSource( - previousValue: previousValue, - newValue: newValue, - previousValidationError: previousValidationError, - newValidationError: validationError, - animated: animated - ) - } - - private func updateSheet(previousValue: AccessMethodViewModel?, newValue: AccessMethodViewModel, animated: Bool) { - guard previousValue?.testingStatus != newValue.testingStatus else { return } - - switch newValue.testingStatus { - case .initial: - sheetPresentation.hide(animated: animated) - - case .inProgress, .failed, .succeeded: - var presentationConfiguration = AccessMethodActionSheetPresentationConfiguration() - presentationConfiguration.sheetConfiguration.context = .addNew - presentationConfiguration.sheetConfiguration.contentConfiguration.status = newValue.testingStatus - .sheetStatus - sheetPresentation.configuration = presentationConfiguration - - sheetPresentation.show(in: view, animated: animated) - } - } - - private func updateDataSource( - previousValue: AccessMethodViewModel?, - newValue: AccessMethodViewModel, - previousValidationError: AccessMethodValidationError?, - newValidationError: AccessMethodValidationError?, - animated: Bool - ) { - var snapshot = NSDiffableDataSourceSnapshot() - - snapshot.appendSections([.name, .protocol]) - snapshot.appendItems([.name], toSection: .name) - - snapshot.appendItems([.protocol], toSection: .protocol) - // Reconfigure the protocol item on the access method change. - if let previousValue, previousValue.method != newValue.method { - snapshot.reconfigureOrReloadItems([.protocol]) - } - - if newValue.method.hasProxyConfiguration { - snapshot.appendSections([.proxyConfiguration]) - } - - switch newValue.method { - case .direct, .bridges: - break - - case .shadowsocks: - snapshot.appendItems(AddAccessMethodItemIdentifier.allShadowsocksItems, toSection: .proxyConfiguration) - // Reconfigure cipher item on change. - if let previousValue, previousValue.shadowsocks.cipher != newValue.shadowsocks.cipher { - snapshot.reconfigureOrReloadItems([.proxyConfiguration(.shadowsocks(.cipher))]) - } - - case .socks5: - snapshot.appendItems( - AddAccessMethodItemIdentifier.allSocksItems(authenticate: newValue.socks.authenticate), - toSection: .proxyConfiguration - ) - } - - dataSource?.apply(snapshot, animatingDifferences: animated) - } - - // MARK: - Misc - - private func configureTableView() { - tableView.delegate = self - tableView.backgroundColor = .secondaryColor - - view.addConstrainedSubviews([tableView]) { - tableView.pinEdgesToSuperview() - } - - addChild(contentController) - contentController.didMove(toParent: self) - } - - private func configureNavigationItem() { - navigationItem.prompt = NSLocalizedString( - "ADD_METHOD_NAVIGATION_PROMPT", - tableName: "APIAccess", - value: "The app will test the method before adding it.", - comment: "" - ) - navigationItem.title = NSLocalizedString( - "ADD_METHOD_NAVIGATION_TITLE", - tableName: "APIAccess", - value: "Add access method", - comment: "" - ) - navigationItem.leftBarButtonItem = cancelBarButton - navigationItem.rightBarButtonItem = addBarButton - } - - private func validate() { - let validationResult = Result { try viewModelSubject.value.validate() } - validationError = validationResult.error as? AccessMethodValidationError - } - - private func updateBarButtons(newValue: AccessMethodViewModel) { - addBarButton.isEnabled = newValue.testingStatus == .initial && validationError == nil - cancelBarButton.isEnabled = newValue.testingStatus == .initial - } - - private func updateModalPresentation(newValue: AccessMethodViewModel) { - // Prevent swipe gesture when testing or when the sheet offers user actions. - isModalInPresentation = newValue.testingStatus != .initial - } - - private func onAdd() { - view.endEditing(true) - - interactor.startProxyConfigurationTest { [weak self] succeeded in - if succeeded { - self?.addMethodAndNotifyDelegate(afterDelay: true) - } - } - } - - private func onCancel() { - view.endEditing(true) - interactor.cancelProxyConfigurationTest() - - delegate?.controllerDidCancel(self) - } - - /// Tells interactor to add the access method and then notifies the delegate which then dismisses the view controller. - /// - Parameter afterDelay: whether to add a short delay before calling the delegate. - private func addMethodAndNotifyDelegate(afterDelay: Bool) { - interactor.addMethod() - - guard afterDelay else { - sendControllerDidAdd() - return - } - - // Add a short delay to let user see the sheet with successful status before the delegate dismisses the view - // controller. - DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) { [weak self] in - self?.sendControllerDidAdd() - } - } - - private func sendControllerDidAdd() { - delegate?.controllerDidAdd(self) - } -} - -extension AddAccessMethodViewController: AccessMethodActionSheetPresentationDelegate { - func sheetDidAdd(sheetPresentation: AccessMethodActionSheetPresentation) { - addMethodAndNotifyDelegate(afterDelay: false) - } - - func sheetDidCancel(sheetPresentation: AccessMethodActionSheetPresentation) { - interactor.cancelProxyConfigurationTest() - } -} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Add/AddAccessMethodViewControllerDelegate.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Add/AddAccessMethodViewControllerDelegate.swift deleted file mode 100644 index 2dadc4cb71ec..000000000000 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Add/AddAccessMethodViewControllerDelegate.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// AddAccessMethodViewControllerDelegate.swift -// MullvadVPN -// -// Created by pronebird on 23/11/2023. -// Copyright © 2023 Mullvad VPN AB. All rights reserved. -// - -import Foundation - -protocol AddAccessMethodViewControllerDelegate: AnyObject { - /// The view controller added the API access method. - /// - /// The delegate should consider dismissing the view controller. - /// - /// - Parameter controller: the calling view controller. - func controllerDidAdd(_ controller: AddAccessMethodViewController) - - /// The user cancelled the view controller. - /// - /// The delegate should consider dismissing the view controller. - /// - /// - Parameter controller: the calling view controller. - func controllerDidCancel(_ controller: AddAccessMethodViewController) - - /// The view controller requests the delegate to present the API access method protocol picker. - /// - /// - Parameter controller: the calling view controller. - func controllerShouldShowProtocolPicker(_ controller: AddAccessMethodViewController) - - /// The view controller requests the delegate to present the cipher picker. - /// - /// - Parameter controller: the calling view controller. - func controllerShouldShowShadowsocksCipherPicker(_ controller: AddAccessMethodViewController) -} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/ButtonCellContentView.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/ButtonCellContentView.swift index 66d50d119914..c002057ac568 100644 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/ButtonCellContentView.swift +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/ButtonCellContentView.swift @@ -12,6 +12,9 @@ import UIKit class ButtonCellContentView: UIView, UIContentView { private let button = AppButton() + /// Default cell corner radius in inset grouped table view + private let tableViewCellCornerRadius: CGFloat = 10 + var configuration: UIContentConfiguration { get { actualConfiguration @@ -60,6 +63,7 @@ class ButtonCellContentView: UIView, UIContentView { private func configureButton() { button.setTitle(actualConfiguration.text, for: .normal) + button.titleLabel?.font = .systemFont(ofSize: 17) button.isEnabled = actualConfiguration.isEnabled button.style = actualConfiguration.style button.overrideContentEdgeInsets = true diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/MethodTestingStatusCellContentConfiguration.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/MethodTestingStatusCellContentConfiguration.swift deleted file mode 100644 index eaa9d07f7df6..000000000000 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/MethodTestingStatusCellContentConfiguration.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// MethodTestingStatusCellContentConfiguration.swift -// MullvadVPN -// -// Created by pronebird on 27/11/2023. -// Copyright © 2023 Mullvad VPN AB. All rights reserved. -// - -import UIKit - -/// Content configuration for presenting the access method testing progress. -struct MethodTestingStatusCellContentConfiguration: UIContentConfiguration, Equatable { - /// Sheet content configuration. - var sheetConfiguration = AccessMethodActionSheetContentConfiguration() - - /// Layout margins. - var directionalLayoutMargins: NSDirectionalEdgeInsets = UIMetrics.SettingsCell.insetLayoutMargins - - func makeContentView() -> UIView & UIContentView { - return MethodTestingStatusCellContentView(configuration: self) - } - - func updated(for state: UIConfigurationState) -> Self { - return self - } -} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/MethodTestingStatusCellContentView.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/MethodTestingStatusCellContentView.swift deleted file mode 100644 index 6c3f1bb68e93..000000000000 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/MethodTestingStatusCellContentView.swift +++ /dev/null @@ -1,66 +0,0 @@ -// -// MethodTestingStatusContentCell.swift -// MullvadVPN -// -// Created by pronebird on 27/11/2023. -// Copyright © 2023 Mullvad VPN AB. All rights reserved. -// - -import UIKit - -/// Content view presenting the access method testing progress. -class MethodTestingStatusCellContentView: UIView, UIContentView { - var configuration: UIContentConfiguration { - get { - actualConfiguration - } - set { - guard let newConfiguration = newValue as? MethodTestingStatusCellContentConfiguration, - actualConfiguration != newConfiguration else { return } - - let previousConfiguration = actualConfiguration - actualConfiguration = newConfiguration - - configureSubviews(previousConfiguration: previousConfiguration) - } - } - - private var actualConfiguration: MethodTestingStatusCellContentConfiguration - private let sheetContentView = AccessMethodActionSheetContentView() - - func supports(_ configuration: UIContentConfiguration) -> Bool { - configuration is MethodTestingStatusCellContentConfiguration - } - - init(configuration: MethodTestingStatusCellContentConfiguration) { - actualConfiguration = configuration - - super.init(frame: CGRect(x: 0, y: 0, width: 100, height: 44)) - - configureSubviews() - addSubviews() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func addSubviews() { - addConstrainedSubviews([sheetContentView]) { - sheetContentView.pinEdgesToSuperviewMargins() - } - } - - private func configureSubviews(previousConfiguration: MethodTestingStatusCellContentConfiguration? = nil) { - configureLayoutMargins() - configureSheetContentView() - } - - private func configureLayoutMargins() { - directionalLayoutMargins = actualConfiguration.directionalLayoutMargins - } - - private func configureSheetContentView() { - sheetContentView.configuration = actualConfiguration.sheetConfiguration - } -} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/SwitchCellContentConfiguration.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/SwitchCellContentConfiguration.swift index 766f9b91db1f..55d9c01533d4 100644 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/SwitchCellContentConfiguration.swift +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/SwitchCellContentConfiguration.swift @@ -28,7 +28,7 @@ struct SwitchCellContentConfiguration: UIContentConfiguration, Equatable { var textProperties = TextProperties() /// Content view layout margins. - var directionalLayoutMargins: NSDirectionalEdgeInsets = UIMetrics.SettingsCell.insetLayoutMargins + var directionalLayoutMargins: NSDirectionalEdgeInsets = UIMetrics.SettingsCell.apiAccessInsetLayoutMargins func makeContentView() -> UIView & UIContentView { return SwitchCellContentView(configuration: self) diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/SwitchCellContentView.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/SwitchCellContentView.swift index 5f50efd035f3..a0a0d66eece6 100644 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/SwitchCellContentView.swift +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/SwitchCellContentView.swift @@ -37,7 +37,7 @@ class SwitchCellContentView: UIView, UIContentView, UITextFieldDelegate { init(configuration: SwitchCellContentConfiguration) { actualConfiguration = configuration - super.init(frame: CGRect(x: 0, y: 0, width: 100, height: 44)) + super.init(frame: CGRect(x: 0, y: 0, width: 100, height: 0)) configureSubviews() addSubviews() @@ -75,13 +75,14 @@ class SwitchCellContentView: UIView, UIContentView, UITextFieldDelegate { private func configureSwitch() { switchContainer.control.isOn = actualConfiguration.isOn + switchContainer.transform = CGAffineTransform(scaleX: 0.85, y: 0.85) } private func addSubviews() { addConstrainedSubviews([textLabel, switchContainer]) { textLabel.pinEdgesToSuperviewMargins(.all().excluding(.trailing)) switchContainer.centerYAnchor.constraint(equalTo: centerYAnchor) - switchContainer.pinEdgeToSuperviewMargin(.trailing(0)) + switchContainer.pinEdgeToSuperview(.trailing(UIMetrics.SettingsCell.apiAccessSwitchCellTrailingMargin)) switchContainer.leadingAnchor.constraint( greaterThanOrEqualToSystemSpacingAfter: textLabel.trailingAnchor, multiplier: 1 diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/TextCellContentConfiguration.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/TextCellContentConfiguration.swift index 33311ff64fd9..2eddbf041069 100644 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/TextCellContentConfiguration.swift +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/TextCellContentConfiguration.swift @@ -19,6 +19,9 @@ struct TextCellContentConfiguration: UIContentConfiguration, Equatable { /// The text input filter that can be used to prevent user from entering illegal characters. var inputFilter: TextInputFilter = .allowAll + /// The maximum input length. + var maxLength: Int? + /// The text field placeholder. var placeholder: String? @@ -32,7 +35,7 @@ struct TextCellContentConfiguration: UIContentConfiguration, Equatable { var textFieldProperties = TextFieldProperties() /// The content view layout margins. - var directionalLayoutMargins: NSDirectionalEdgeInsets = UIMetrics.SettingsCell.insetLayoutMargins + var directionalLayoutMargins: NSDirectionalEdgeInsets = UIMetrics.SettingsCell.apiAccessInsetLayoutMargins func makeContentView() -> UIView & UIContentView { return TextCellContentView(configuration: self) diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/TextCellContentView.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/TextCellContentView.swift index 9b56cc862750..c41123906d8e 100644 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/TextCellContentView.swift +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/TextCellContentView.swift @@ -156,6 +156,15 @@ extension TextCellContentView: UITextFieldDelegate { shouldChangeCharactersIn range: NSRange, replacementString string: String ) -> Bool { + guard + let currentString = textField.text, + let stringRange = Range(range, in: currentString) else { return false } + let updatedText = currentString.replacingCharacters(in: stringRange, with: string) + + if let maxLength = actualConfiguration.maxLength, maxLength < updatedText.count { + return false + } + switch actualConfiguration.inputFilter { case .allowAll: return true diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Common/AccessMethodCellReuseIdentifier.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Common/AccessMethodCellReuseIdentifier.swift index e6c4427aaeee..a46418d85c73 100644 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Common/AccessMethodCellReuseIdentifier.swift +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Common/AccessMethodCellReuseIdentifier.swift @@ -22,6 +22,9 @@ enum AccessMethodCellReuseIdentifier: String, CaseIterable, CellIdentifierProtoc /// Cells that contain a button. case button + /// Cells that contain a number of validation errors. + case validationError + /// Cells that contain the status of API method testing. case testingStatus diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Common/AccessMethodViewModelEditing.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Common/AccessMethodViewModelEditing.swift new file mode 100644 index 000000000000..59650aa15d1e --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Common/AccessMethodViewModelEditing.swift @@ -0,0 +1,13 @@ +// +// AccessMethodViewModelEditing.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-01-23. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import MullvadSettings + +protocol AccessMethodEditing { + func accessMethodDidSave(_ accessMethod: PersistentAccessMethod) +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Common/ShadowsocksSectionHandler.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Common/ShadowsocksSectionHandler.swift index fc1a19ed5f21..1f5fc64527b3 100644 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Common/ShadowsocksSectionHandler.swift +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Common/ShadowsocksSectionHandler.swift @@ -11,6 +11,8 @@ import UIKit /// Type responsible for handling cells in shadowsocks table view section. struct ShadowsocksSectionHandler { + private let authenticationInputMaxLength = 2048 + let tableStyle: UITableView.Style let subject: CurrentValueSubject @@ -54,6 +56,7 @@ struct ShadowsocksSectionHandler { func configurePassword(_ cell: UITableViewCell, itemIdentifier: ShadowsocksItemIdentifier) { var contentConfiguration = TextCellContentConfiguration() contentConfiguration.text = itemIdentifier.text + contentConfiguration.maxLength = authenticationInputMaxLength contentConfiguration.setPlaceholder(type: .optional) contentConfiguration.inputText = subject.value.shadowsocks.password contentConfiguration.editingEvents.onChange = subject.bindTextAction(to: \.shadowsocks.password) diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Common/SocksSectionHandler.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Common/SocksSectionHandler.swift index 7795102e9747..201ec4e04c50 100644 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Common/SocksSectionHandler.swift +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Common/SocksSectionHandler.swift @@ -11,6 +11,8 @@ import UIKit /// Type responsible for handling cells in socks table view section. struct SocksSectionHandler { + private let authenticationInputMaxLength = 255 + let tableStyle: UITableView.Style let subject: CurrentValueSubject @@ -64,6 +66,7 @@ struct SocksSectionHandler { private func configureUsername(_ cell: UITableViewCell, itemIdentifier: SocksItemIdentifier) { var contentConfiguration = TextCellContentConfiguration() contentConfiguration.text = itemIdentifier.text + contentConfiguration.maxLength = authenticationInputMaxLength contentConfiguration.setPlaceholder(type: .required) contentConfiguration.inputText = subject.value.socks.username contentConfiguration.textFieldProperties = .withSmartFeaturesDisabled() @@ -75,7 +78,8 @@ struct SocksSectionHandler { private func configurePassword(_ cell: UITableViewCell, itemIdentifier: SocksItemIdentifier) { var contentConfiguration = TextCellContentConfiguration() contentConfiguration.text = itemIdentifier.text - contentConfiguration.setPlaceholder(type: .optional) + contentConfiguration.maxLength = authenticationInputMaxLength + contentConfiguration.setPlaceholder(type: .required) contentConfiguration.inputText = subject.value.socks.password contentConfiguration.editingEvents.onChange = subject.bindTextAction(to: \.socks.password) contentConfiguration.textFieldProperties = .withSmartFeaturesDisabled() diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/EditAccessMethodCoordinator.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/EditAccessMethodCoordinator.swift index 4e7d5c17b72b..de77f93a43fc 100644 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/EditAccessMethodCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/EditAccessMethodCoordinator.swift @@ -11,15 +11,20 @@ import MullvadSettings import Routing import UIKit -class EditAccessMethodCoordinator: Coordinator { +class EditAccessMethodCoordinator: Coordinator, Presenting { let navigationController: UINavigationController - let subject: CurrentValueSubject = .init(AccessMethodViewModel()) - let accessMethodRepo: AccessMethodRepositoryProtocol let proxyConfigurationTester: ProxyConfigurationTesterProtocol + let accessMethodRepository: AccessMethodRepositoryProtocol let methodIdentifier: UUID + var methodSettingsSubject: CurrentValueSubject = .init(AccessMethodViewModel()) + var editAccessMethodSubject: CurrentValueSubject = .init(AccessMethodViewModel()) var onFinish: ((EditAccessMethodCoordinator) -> Void)? + var presentationContext: UIViewController { + navigationController + } + init( navigationController: UINavigationController, accessMethodRepo: AccessMethodRepositoryProtocol, @@ -27,22 +32,25 @@ class EditAccessMethodCoordinator: Coordinator { methodIdentifier: UUID ) { self.navigationController = navigationController - self.accessMethodRepo = accessMethodRepo + self.accessMethodRepository = accessMethodRepo self.proxyConfigurationTester = proxyConfigurationTester self.methodIdentifier = methodIdentifier } func start() { - guard let persistentMethod = accessMethodRepo.fetch(by: methodIdentifier) else { return } - - subject.value = persistentMethod.toViewModel() + editAccessMethodSubject = getViewModelSubjectFromStore() let interactor = EditAccessMethodInteractor( - subject: subject, - repo: accessMethodRepo, + subject: editAccessMethodSubject, + repository: accessMethodRepository, proxyConfigurationTester: proxyConfigurationTester ) - let controller = EditAccessMethodViewController(subject: subject, interactor: interactor) + + let controller = EditAccessMethodViewController( + subject: editAccessMethodSubject, + interactor: interactor, + alertPresenter: AlertPresenter(context: self) + ) controller.delegate = self navigationController.pushViewController(controller, animated: true) @@ -50,17 +58,42 @@ class EditAccessMethodCoordinator: Coordinator { } extension EditAccessMethodCoordinator: EditAccessMethodViewControllerDelegate { - func controllerDidSaveAccessMethod(_ controller: EditAccessMethodViewController) { - onFinish?(self) - } + func controllerShouldShowMethodSettings(_ controller: EditAccessMethodViewController) { + methodSettingsSubject = getViewModelSubjectFromStore() - func controllerShouldShowProxyConfiguration(_ controller: EditAccessMethodViewController) { let interactor = EditAccessMethodInteractor( - subject: subject, - repo: accessMethodRepo, + subject: methodSettingsSubject, + repository: accessMethodRepository, proxyConfigurationTester: proxyConfigurationTester ) - let controller = ProxyConfigurationViewController(subject: subject, interactor: interactor) + + let controller = MethodSettingsViewController( + subject: methodSettingsSubject, + interactor: interactor, + alertPresenter: AlertPresenter(context: self) + ) + + controller.navigationItem.prompt = NSLocalizedString( + "METHOD_SETTINGS_NAVIGATION_ADD_PROMPT", + tableName: "APIAccess", + value: "The app will test the method before saving.", + comment: "" + ) + + controller.navigationItem.title = NSLocalizedString( + "METHOD_SETTINGS_NAVIGATION_ADD_TITLE", + tableName: "APIAccess", + value: "Method settings", + comment: "" + ) + + controller.saveBarButton.title = NSLocalizedString( + "METHOD_SETTINGS_NAVIGATION_ADD_BUTTON", + tableName: "APIAccess", + value: "Save", + comment: "" + ) + controller.delegate = self navigationController.pushViewController(controller, animated: true) @@ -69,22 +102,32 @@ extension EditAccessMethodCoordinator: EditAccessMethodViewControllerDelegate { func controllerDidDeleteAccessMethod(_ controller: EditAccessMethodViewController) { onFinish?(self) } + + private func getViewModelSubjectFromStore() -> CurrentValueSubject { + let persistentMethod = accessMethodRepository.fetch(by: methodIdentifier) + return CurrentValueSubject(persistentMethod?.toViewModel() ?? .init()) + } } -extension EditAccessMethodCoordinator: ProxyConfigurationViewControllerDelegate { - func controllerShouldShowProtocolPicker(_ controller: ProxyConfigurationViewController) { +extension EditAccessMethodCoordinator: MethodSettingsViewControllerDelegate { + func accessMethodDidSave(_ accessMethod: PersistentAccessMethod) { + editAccessMethodSubject.value = accessMethod.toViewModel() + navigationController.popViewController(animated: true) + } + + func controllerShouldShowProtocolPicker(_ controller: MethodSettingsViewController) { let picker = AccessMethodProtocolPicker(navigationController: navigationController) - picker.present(currentValue: subject.value.method) { [weak self] newMethod in - self?.subject.value.method = newMethod + picker.present(currentValue: methodSettingsSubject.value.method) { [weak self] newMethod in + self?.methodSettingsSubject.value.method = newMethod } } - func controllerShouldShowShadowsocksCipherPicker(_ controller: ProxyConfigurationViewController) { + func controllerShouldShowShadowsocksCipherPicker(_ controller: MethodSettingsViewController) { let picker = ShadowsocksCipherPicker(navigationController: navigationController) - picker.present(currentValue: subject.value.shadowsocks.cipher) { [weak self] selectedCipher in - self?.subject.value.shadowsocks.cipher = selectedCipher + picker.present(currentValue: methodSettingsSubject.value.shadowsocks.cipher) { [weak self] selectedCipher in + self?.methodSettingsSubject.value.shadowsocks.cipher = selectedCipher } } } diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/EditAccessMethodInteractor.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/EditAccessMethodInteractor.swift index 5af1f9bf7937..b33e3c227c23 100644 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/EditAccessMethodInteractor.swift +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/EditAccessMethodInteractor.swift @@ -12,17 +12,29 @@ import MullvadSettings struct EditAccessMethodInteractor: EditAccessMethodInteractorProtocol { let subject: CurrentValueSubject - let repo: AccessMethodRepositoryProtocol + let repository: AccessMethodRepositoryProtocol let proxyConfigurationTester: ProxyConfigurationTesterProtocol + var directAccess: PersistentAccessMethod { + repository.directAccess + } + + var publisher: AnyPublisher<[PersistentAccessMethod], Never> { + repository.publisher.eraseToAnyPublisher() + } + func saveAccessMethod() { guard let persistentMethod = try? subject.value.intoPersistentAccessMethod() else { return } - repo.update(persistentMethod) + repository.save(persistentMethod) } func deleteAccessMethod() { - repo.delete(id: subject.value.id) + repository.delete(id: subject.value.id) + } + + func fetchAll() -> [MullvadSettings.PersistentAccessMethod] { + return repository.fetchAll() } func startProxyConfigurationTest(_ completion: ((Bool) -> Void)?) { diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/EditAccessMethodInteractorProtocol.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/EditAccessMethodInteractorProtocol.swift index 57c383c8bc80..870224bd8e92 100644 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/EditAccessMethodInteractorProtocol.swift +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/EditAccessMethodInteractorProtocol.swift @@ -6,11 +6,10 @@ // Copyright © 2023 Mullvad VPN AB. All rights reserved. // -import Foundation -import MullvadTypes +import MullvadSettings /// The type implementing the interface for persisting changes to the underlying access method view model in the editing context. -protocol EditAccessMethodInteractorProtocol: ProxyConfigurationInteractorProtocol { +protocol EditAccessMethodInteractorProtocol: ProxyConfigurationInteractorProtocol, AccessMethodRepositoryDataSource { /// Save changes to persistent store. /// /// - Calling this method when the underlying view model fails validation does nothing. diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/EditAccessMethodItemIdentifier.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/EditAccessMethodItemIdentifier.swift index a45ebdcb714c..1667746f6b14 100644 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/EditAccessMethodItemIdentifier.swift +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/EditAccessMethodItemIdentifier.swift @@ -9,23 +9,21 @@ import Foundation enum EditAccessMethodItemIdentifier: Hashable { - case name - case useIfAvailable - case proxyConfiguration + case enableMethod + case methodSettings case testMethod case testingStatus + case cancelTest case deleteMethod /// Cell identifier for the item identifier. var cellIdentifier: AccessMethodCellReuseIdentifier { switch self { - case .name: - .textInput - case .useIfAvailable: + case .enableMethod: .toggle - case .proxyConfiguration: + case .methodSettings: .textWithDisclosure - case .testMethod, .deleteMethod: + case .testMethod, .cancelTest, .deleteMethod: .button case .testingStatus: .testingStatus @@ -35,9 +33,9 @@ enum EditAccessMethodItemIdentifier: Hashable { /// Returns `true` if the cell background should be made transparent. var isClearBackground: Bool { switch self { - case .testMethod, .testingStatus, .deleteMethod: + case .testMethod, .cancelTest, .testingStatus, .deleteMethod: return true - case .name, .useIfAvailable, .proxyConfiguration: + case .enableMethod, .methodSettings: return false } } @@ -45,9 +43,9 @@ enum EditAccessMethodItemIdentifier: Hashable { /// Whether cell representing the item should be selectable. var isSelectable: Bool { switch self { - case .name, .useIfAvailable, .testMethod, .testingStatus, .deleteMethod: + case .enableMethod, .testMethod, .cancelTest, .testingStatus, .deleteMethod: false - case .proxyConfiguration: + case .methodSettings: true } } @@ -55,14 +53,14 @@ enum EditAccessMethodItemIdentifier: Hashable { /// The text label for the corresponding cell. var text: String? { switch self { - case .name: - NSLocalizedString("NAME", tableName: "APIAccess", value: "Name", comment: "") - case .useIfAvailable: - NSLocalizedString("USE_IF_AVAILABLE", tableName: "APIAccess", value: "Use if available", comment: "") - case .proxyConfiguration: - NSLocalizedString("PROXY_CONFIGURATION", tableName: "APIAccess", value: "Proxy configuration", comment: "") + case .enableMethod: + NSLocalizedString("ENABLE_METHOD", tableName: "APIAccess", value: "Enable method", comment: "") + case .methodSettings: + NSLocalizedString("METHOD_SETTINGS", tableName: "APIAccess", value: "Method settings", comment: "") case .testMethod: NSLocalizedString("TEST_METHOD", tableName: "APIAccess", value: "Test method", comment: "") + case .cancelTest: + NSLocalizedString("CANCEL_TEST", tableName: "APIAccess", value: "Cancel", comment: "") case .testingStatus: nil case .deleteMethod: diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/EditAccessMethodSectionIdentifier.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/EditAccessMethodSectionIdentifier.swift index 0f62f94041f0..cd8f978113a5 100644 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/EditAccessMethodSectionIdentifier.swift +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/EditAccessMethodSectionIdentifier.swift @@ -9,41 +9,34 @@ import Foundation enum EditAccessMethodSectionIdentifier: Hashable { - case name + case enableMethod + case methodSettings case testMethod - case useIfAvailable - case proxyConfiguration + case cancelTest + case testingStatus case deleteMethod /// The section footer text. var sectionFooter: String? { switch self { - case .name, .deleteMethod: - nil - - case .testMethod: + case .enableMethod: NSLocalizedString( - "TEST_METHOD_FOOTER", + "ENABLE_METHOD_FOOTER", tableName: "APIAccess", - value: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt", + value: "When enabled, the app can try to communicate with a Mullvad API server using this method.", comment: "" ) - case .useIfAvailable: + case .testMethod: NSLocalizedString( - "USE_IF_AVAILABLE_FOOTER", + "TEST_METHOD_FOOTER", tableName: "APIAccess", - value: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt", + value: "Performs a connection test to a Mullvad API server via this access method.", comment: "" ) - case .proxyConfiguration: - NSLocalizedString( - "PROXY_CONFIGURATION_FOOTER", - tableName: "APIAccess", - value: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt", - comment: "" - ) + case .methodSettings, .cancelTest, .testingStatus, .deleteMethod: + nil } } } diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/EditAccessMethodViewController.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/EditAccessMethodViewController.swift index d961906925ed..e7606d3c8108 100644 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/EditAccessMethodViewController.swift +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/EditAccessMethodViewController.swift @@ -11,27 +11,28 @@ import UIKit /// The view controller providing the interface for editing the existing access method. class EditAccessMethodViewController: UITableViewController { + typealias EditAccessMethodDataSource = UITableViewDiffableDataSource< + EditAccessMethodSectionIdentifier, + EditAccessMethodItemIdentifier + > + private let subject: CurrentValueSubject - private var validationError: AccessMethodValidationError? private let interactor: EditAccessMethodInteractorProtocol + private var alertPresenter: AlertPresenter private var cancellables = Set() - private var dataSource: UITableViewDiffableDataSource< - EditAccessMethodSectionIdentifier, - EditAccessMethodItemIdentifier - >? - private lazy var saveBarButton: UIBarButtonItem = { - let barButton = UIBarButtonItem(systemItem: .save, primaryAction: UIAction { [weak self] _ in - self?.onSave() - }) - barButton.style = .done - return barButton - }() + private var dataSource: EditAccessMethodDataSource? weak var delegate: EditAccessMethodViewControllerDelegate? - init(subject: CurrentValueSubject, interactor: EditAccessMethodInteractorProtocol) { + init( + subject: CurrentValueSubject, + interactor: EditAccessMethodInteractorProtocol, + alertPresenter: AlertPresenter + ) { self.subject = subject self.interactor = interactor + self.alertPresenter = alertPresenter + super.init(style: .insetGrouped) } @@ -44,11 +45,21 @@ class EditAccessMethodViewController: UITableViewController { view.backgroundColor = .secondaryColor tableView.backgroundColor = .secondaryColor + navigationItem.largeTitleDisplayMode = .never + + isModalInPresentation = true configureDataSource() configureNavigationItem() } + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + interactor.cancelProxyConfigurationTest() + } + + // MARK: - UITableViewDelegate + override func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool { guard let itemIdentifier = dataSource?.itemIdentifier(for: indexPath) else { return false } @@ -58,8 +69,30 @@ class EditAccessMethodViewController: UITableViewController { override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { guard let itemIdentifier = dataSource?.itemIdentifier(for: indexPath) else { return } - if case .proxyConfiguration = itemIdentifier { - delegate?.controllerShouldShowProxyConfiguration(self) + if case .methodSettings = itemIdentifier { + delegate?.controllerShouldShowMethodSettings(self) + } + } + + override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + return UIMetrics.SettingsCell.apiAccessCellHeight + } + + override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + return nil + } + + // Header height shenanigans to avoid extra spacing in testing sections when testing is NOT ongoing. + override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + guard let sectionIdentifier = dataSource?.snapshot().sectionIdentifiers[section] else { return 0 } + + switch sectionIdentifier { + case .enableMethod, .methodSettings, .deleteMethod, .testMethod: + return UITableView.automaticDimension + case .testingStatus: + return subject.value.testingStatus == .initial ? 0 : UITableView.automaticDimension + case .cancelTest: + return 0 } } @@ -71,7 +104,7 @@ class EditAccessMethodViewController: UITableViewController { .dequeueReusableView(withIdentifier: AccessMethodHeaderFooterReuseIdentifier.primary) else { return nil } - var contentConfiguration = UIListContentConfiguration.mullvadGroupedFooter() + var contentConfiguration = UIListContentConfiguration.mullvadGroupedFooter(tableStyle: tableView.style) contentConfiguration.text = sectionFooterText headerView.contentConfiguration = contentConfiguration @@ -79,6 +112,26 @@ class EditAccessMethodViewController: UITableViewController { return headerView } + // Footer height shenanigans to avoid extra spacing in testing sections when testing is NOT ongoing. + override func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { + guard let sectionIdentifier = dataSource?.snapshot().sectionIdentifiers[section] else { return 0 } + let marginToDeleteMethodItem: CGFloat = 24 + + switch sectionIdentifier { + case .enableMethod, .methodSettings, .deleteMethod, .testMethod: + return UITableView.automaticDimension + case .testingStatus: + switch subject.value.testingStatus { + case .initial, .inProgress: + return 0 + case .succeeded, .failed: + return marginToDeleteMethodItem + } + case .cancelTest: + return subject.value.testingStatus == .inProgress ? marginToDeleteMethodItem : 0 + } + } + // MARK: - Cell configuration private func dequeueCell(at indexPath: IndexPath, for itemIdentifier: EditAccessMethodItemIdentifier) @@ -88,18 +141,18 @@ class EditAccessMethodViewController: UITableViewController { configureBackground(cell: cell, itemIdentifier: itemIdentifier) switch itemIdentifier { - case .name: - configureName(cell, itemIdentifier: itemIdentifier) case .testMethod: configureTestMethod(cell, itemIdentifier: itemIdentifier) + case .cancelTest: + configureCancelTest(cell, itemIdentifier: itemIdentifier) case .testingStatus: configureTestingStatus(cell, itemIdentifier: itemIdentifier) case .deleteMethod: configureDeleteMethod(cell, itemIdentifier: itemIdentifier) - case .useIfAvailable: - configureUseIfAvailable(cell, itemIdentifier: itemIdentifier) - case .proxyConfiguration: - configureProxyConfiguration(cell, itemIdentifier: itemIdentifier) + case .enableMethod: + configureEnableMethod(cell, itemIdentifier: itemIdentifier) + case .methodSettings: + configureMethodSettings(cell, itemIdentifier: itemIdentifier) } return cell @@ -113,52 +166,49 @@ class EditAccessMethodViewController: UITableViewController { return } - var backgroundConfiguration = UIBackgroundConfiguration.mullvadListGroupedCell() - - if case .proxyConfiguration = itemIdentifier, let validationError, - validationError.containsProxyConfigurationErrors(selectedMethod: subject.value.method) { - backgroundConfiguration.applyValidationErrorStyle() - } - - cell.setAutoAdaptingBackgroundConfiguration(backgroundConfiguration, selectionType: .dimmed) + cell.setAutoAdaptingBackgroundConfiguration(.mullvadListGroupedCell(), selectionType: .dimmed) } - private func configureName(_ cell: UITableViewCell, itemIdentifier: EditAccessMethodItemIdentifier) { - var contentConfiguration = TextCellContentConfiguration() + private func configureTestMethod(_ cell: UITableViewCell, itemIdentifier: EditAccessMethodItemIdentifier) { + var contentConfiguration = ButtonCellContentConfiguration() contentConfiguration.text = itemIdentifier.text - contentConfiguration.setPlaceholder(type: .optional) - contentConfiguration.textFieldProperties = .withAutoResignAndDoneReturnKey() - contentConfiguration.inputText = subject.value.name - contentConfiguration.editingEvents.onChange = subject.bindTextAction(to: \.name) + contentConfiguration.isEnabled = subject.value.testingStatus != .inProgress + contentConfiguration.primaryAction = UIAction { [weak self] _ in + self?.onTest() + } cell.contentConfiguration = contentConfiguration } - private func configureTestMethod(_ cell: UITableViewCell, itemIdentifier: EditAccessMethodItemIdentifier) { + private func configureCancelTest(_ cell: UITableViewCell, itemIdentifier: EditAccessMethodItemIdentifier) { var contentConfiguration = ButtonCellContentConfiguration() - contentConfiguration.style = .tableInsetGroupedSuccess contentConfiguration.text = itemIdentifier.text - contentConfiguration.isEnabled = subject.value.testingStatus != .inProgress + contentConfiguration.isEnabled = subject.value.testingStatus == .inProgress contentConfiguration.primaryAction = UIAction { [weak self] _ in - self?.onTest() + self?.onCancelTest() } cell.contentConfiguration = contentConfiguration } private func configureTestingStatus(_ cell: UITableViewCell, itemIdentifier: EditAccessMethodItemIdentifier) { var contentConfiguration = MethodTestingStatusCellContentConfiguration() - contentConfiguration.sheetConfiguration = .init(status: subject.value.testingStatus.sheetStatus) + contentConfiguration.status = subject.value.testingStatus.viewStatus cell.contentConfiguration = contentConfiguration } - private func configureUseIfAvailable(_ cell: UITableViewCell, itemIdentifier: EditAccessMethodItemIdentifier) { + private func configureEnableMethod(_ cell: UITableViewCell, itemIdentifier: EditAccessMethodItemIdentifier) { var contentConfiguration = SwitchCellContentConfiguration() contentConfiguration.text = itemIdentifier.text contentConfiguration.isOn = subject.value.isEnabled - contentConfiguration.onChange = subject.bindSwitchAction(to: \.isEnabled) + contentConfiguration.onChange = UIAction { [weak self] action in + if let customSwitch = action.sender as? UISwitch { + self?.subject.value.isEnabled = customSwitch.isOn + self?.onSave() + } + } cell.contentConfiguration = contentConfiguration } - private func configureProxyConfiguration(_ cell: UITableViewCell, itemIdentifier: EditAccessMethodItemIdentifier) { + private func configureMethodSettings(_ cell: UITableViewCell, itemIdentifier: EditAccessMethodItemIdentifier) { var contentConfiguration = UIListContentConfiguration.mullvadCell(tableStyle: tableView.style) contentConfiguration.text = itemIdentifier.text cell.contentConfiguration = contentConfiguration @@ -190,6 +240,7 @@ class EditAccessMethodViewController: UITableViewController { self?.dequeueCell(at: indexPath, for: itemIdentifier) } ) + subject.withPreviousValue() .sink { [weak self] previousValue, newValue in self?.viewModelDidChange(previousValue: previousValue, newValue: newValue) @@ -199,15 +250,11 @@ class EditAccessMethodViewController: UITableViewController { private func viewModelDidChange(previousValue: AccessMethodViewModel?, newValue: AccessMethodViewModel) { let animated = view.window != nil - let previousValidationError = validationError - validateViewModel() - updateBarButtons() + configureNavigationItem() updateDataSource( previousValue: previousValue, newValue: newValue, - previousValidationError: previousValidationError, - newValidationError: validationError, animated: animated ) } @@ -215,45 +262,41 @@ class EditAccessMethodViewController: UITableViewController { private func updateDataSource( previousValue: AccessMethodViewModel?, newValue: AccessMethodViewModel, - previousValidationError: AccessMethodValidationError?, - newValidationError: AccessMethodValidationError?, animated: Bool ) { var snapshot = NSDiffableDataSourceSnapshot() - // Add name field for user-defined access methods. - if !newValue.method.isPermanent { - snapshot.appendSections([.name]) - snapshot.appendItems([.name], toSection: .name) - } + snapshot.appendSections([.enableMethod]) + snapshot.appendItems([.enableMethod], toSection: .enableMethod) - // Add static sections. - snapshot.appendSections([.testMethod, .useIfAvailable]) + // Add method settings if the access method is configurable. + if newValue.method.hasProxyConfiguration { + snapshot.appendSections([.methodSettings]) + snapshot.appendItems([.methodSettings], toSection: .methodSettings) + } + snapshot.appendSections([.testMethod]) snapshot.appendItems([.testMethod], toSection: .testMethod) + // Reconfigure the test button on status changes. if let previousValue, previousValue.testingStatus != newValue.testingStatus { snapshot.reconfigureOrReloadItems([.testMethod]) } + snapshot.appendSections([.testingStatus]) + snapshot.appendSections([.cancelTest]) + // Add test status below the test button. if newValue.testingStatus != .initial { - snapshot.appendItems([.testingStatus], toSection: .testMethod) + snapshot.appendItems([.testingStatus], toSection: .testingStatus) + if let previousValue, previousValue.testingStatus != newValue.testingStatus { snapshot.reconfigureOrReloadItems([.testingStatus]) } - } - - snapshot.appendItems([.useIfAvailable], toSection: .useIfAvailable) - - // Add proxy configuration if the access method is configurable. - if newValue.method.hasProxyConfiguration { - snapshot.appendSections([.proxyConfiguration]) - snapshot.appendItems([.proxyConfiguration], toSection: .proxyConfiguration) - // Reconfigure the proxy configuration cell if validation error changed. - if previousValidationError != newValidationError { - snapshot.reconfigureOrReloadItems([.proxyConfiguration]) + // Show cancel test button below test status. + if newValue.testingStatus == .inProgress { + snapshot.appendItems([.cancelTest], toSection: .cancelTest) } } @@ -270,29 +313,66 @@ class EditAccessMethodViewController: UITableViewController { private func configureNavigationItem() { navigationItem.title = subject.value.navigationItemTitle - navigationItem.rightBarButtonItem = saveBarButton - } - - private func validateViewModel() { - let validationResult = Result { try subject.value.validate() } - validationError = validationResult.error as? AccessMethodValidationError } - private func updateBarButtons() { - saveBarButton.isEnabled = validationError == nil + private func onSave() { + interactor.saveAccessMethod() } private func onDelete() { - interactor.deleteAccessMethod() - delegate?.controllerDidDeleteAccessMethod(self) - } + let methodName = subject.value.name.isEmpty + ? NSLocalizedString( + "METHOD_SETTINGS_SAVE_PROMPT", + tableName: "APIAccess", + value: "method?", + comment: "" + ) + : subject.value.name + + let presentation = AlertPresentation( + id: "api-access-methods-delete-method-alert", + icon: .alert, + message: NSLocalizedString( + "METHOD_SETTINGS_SAVE_PROMPT", + tableName: "APIAccess", + value: "Delete \(methodName)?", + comment: "" + ), + buttons: [ + AlertAction( + title: NSLocalizedString( + "METHOD_SETTINGS_DELETE_BUTTON", + tableName: "APIAccess", + value: "Delete", + comment: "" + ), + style: .destructive, + handler: { [weak self] in + guard let self else { return } + interactor.deleteAccessMethod() + delegate?.controllerDidDeleteAccessMethod(self) + } + ), + AlertAction( + title: NSLocalizedString( + "METHOD_SETTINGS_CANCEL_BUTTON", + tableName: "APIAccess", + value: "Cancel", + comment: "" + ), + style: .default + ), + ] + ) - private func onSave() { - interactor.saveAccessMethod() - delegate?.controllerDidSaveAccessMethod(self) + alertPresenter.showAlert(presentation: presentation, animated: true) } private func onTest() { interactor.startProxyConfigurationTest() } + + private func onCancelTest() { + interactor.cancelProxyConfigurationTest() + } } diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/EditAccessMethodViewControllerDelegate.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/EditAccessMethodViewControllerDelegate.swift index 29e0dc48687a..aee945cc76f7 100644 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/EditAccessMethodViewControllerDelegate.swift +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/EditAccessMethodViewControllerDelegate.swift @@ -11,7 +11,7 @@ import Foundation protocol EditAccessMethodViewControllerDelegate: AnyObject { /// The view controller requests the delegate to present the proxy configuration view controller. /// - Parameter controller: the calling controller. - func controllerShouldShowProxyConfiguration(_ controller: EditAccessMethodViewController) + func controllerShouldShowMethodSettings(_ controller: EditAccessMethodViewController) /// The view controller deleted the access method. /// @@ -19,11 +19,4 @@ protocol EditAccessMethodViewControllerDelegate: AnyObject { /// /// - Parameter controller: the calling controller. func controllerDidDeleteAccessMethod(_ controller: EditAccessMethodViewController) - - /// The view controller saved changes to the access method. - /// - /// The delegate should consider dismissing the view controller. - /// - /// - Parameter controller: the calling controller. - func controllerDidSaveAccessMethod(_ controller: EditAccessMethodViewController) } diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/MethodSettings/MethodSettingsCellConfiguration.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/MethodSettings/MethodSettingsCellConfiguration.swift new file mode 100644 index 000000000000..c9b69ffd20bd --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/MethodSettings/MethodSettingsCellConfiguration.swift @@ -0,0 +1,186 @@ +// +// MethodSettingsCellConfiguration.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-01-19. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Combine +import UIKit + +class MethodSettingsCellConfiguration { + private let subject: CurrentValueSubject + private let tableView: UITableView + + var onCancelTest: (() -> Void)? + + private var isTesting: Bool { + subject.value.testingStatus == .inProgress + } + + init(tableView: UITableView, subject: CurrentValueSubject) { + self.tableView = tableView + self.subject = subject + } + + func dequeueCell( + at indexPath: IndexPath, + for itemIdentifier: MethodSettingsItemIdentifier, + contentValidationErrors: [AccessMethodFieldValidationError] + ) -> UITableViewCell { + let cell = tableView.dequeueReusableView(withIdentifier: itemIdentifier.cellIdentifier, for: indexPath) + + configureBackground( + cell: cell, + itemIdentifier: itemIdentifier, + contentValidationErrors: contentValidationErrors + ) + + switch itemIdentifier { + case .name: + configureName(cell, itemIdentifier: itemIdentifier) + case .protocol: + configureProtocol(cell, itemIdentifier: itemIdentifier) + case let .proxyConfiguration(proxyItemIdentifier): + configureProxy(cell, itemIdentifier: proxyItemIdentifier) + case .validationError: + configureValidationError( + cell, + itemIdentifier: itemIdentifier, + contentValidationErrors: contentValidationErrors + ) + case .testingStatus: + configureTestingStatus(cell, itemIdentifier: itemIdentifier) + case .cancelTest: + configureCancelTest(cell, itemIdentifier: itemIdentifier) + } + + return cell + } + + private func configureBackground( + cell: UITableViewCell, + itemIdentifier: MethodSettingsItemIdentifier, + contentValidationErrors: [AccessMethodFieldValidationError] + ) { + configureErrorState( + cell: cell, + itemIdentifier: itemIdentifier, + contentValidationErrors: contentValidationErrors + ) + + guard let cell = cell as? DynamicBackgroundConfiguration else { return } + + guard !itemIdentifier.isClearBackground else { + cell.setAutoAdaptingClearBackgroundConfiguration() + return + } + + cell.setAutoAdaptingBackgroundConfiguration(.mullvadListGroupedCell(), selectionType: .dimmed) + } + + private func configureErrorState( + cell: UITableViewCell, + itemIdentifier: MethodSettingsItemIdentifier, + contentValidationErrors: [AccessMethodFieldValidationError] + ) { + guard case .proxyConfiguration = itemIdentifier else { + return + } + + let itemsWithErrors = MethodSettingsItemIdentifier.fromFieldValidationErrors( + contentValidationErrors, + selectedMethod: subject.value.method + ) + + if itemsWithErrors.contains(itemIdentifier) { + cell.layer.cornerRadius = 10 + cell.layer.borderWidth = 1 + cell.layer.borderColor = UIColor.Cell.validationErrorBorderColor.cgColor + } else { + cell.layer.borderWidth = 0 + } + } + + private func configureName(_ cell: UITableViewCell, itemIdentifier: MethodSettingsItemIdentifier) { + var contentConfiguration = TextCellContentConfiguration() + contentConfiguration.text = itemIdentifier.text + contentConfiguration.setPlaceholder(type: .required) + contentConfiguration.textFieldProperties = .withSmartFeaturesDisabled() + contentConfiguration.inputText = subject.value.name + contentConfiguration.editingEvents.onChange = subject.bindTextAction(to: \.name) + + cell.setDisabled(isTesting) + cell.contentConfiguration = contentConfiguration + } + + private func configureProxy(_ cell: UITableViewCell, itemIdentifier: ProxyProtocolConfigurationItemIdentifier) { + switch itemIdentifier { + case let .socks(socksItemIdentifier): + let section = SocksSectionHandler(tableStyle: tableView.style, subject: subject) + section.configure(cell, itemIdentifier: socksItemIdentifier) + + case let .shadowsocks(shadowsocksItemIdentifier): + let section = ShadowsocksSectionHandler(tableStyle: tableView.style, subject: subject) + section.configure(cell, itemIdentifier: shadowsocksItemIdentifier) + } + + cell.setDisabled(isTesting) + } + + private func configureValidationError( + _ cell: UITableViewCell, + itemIdentifier: MethodSettingsItemIdentifier, + contentValidationErrors: [AccessMethodFieldValidationError] + ) { + var contentConfiguration = MethodSettingsValidationErrorContentConfiguration() + contentConfiguration.fieldErrors = contentValidationErrors + + cell.contentConfiguration = contentConfiguration + } + + private func configureProtocol(_ cell: UITableViewCell, itemIdentifier: MethodSettingsItemIdentifier) { + var contentConfiguration = UIListContentConfiguration.mullvadValueCell( + tableStyle: tableView.style, + isEnabled: !isTesting + ) + contentConfiguration.text = itemIdentifier.text + contentConfiguration.secondaryText = subject.value.method.localizedDescription + cell.contentConfiguration = contentConfiguration + + if let cell = cell as? CustomCellDisclosureHandling { + cell.disclosureType = .chevron + } + + cell.setDisabled(isTesting) + } + + private func configureCancelTest(_ cell: UITableViewCell, itemIdentifier: MethodSettingsItemIdentifier) { + var contentConfiguration = ButtonCellContentConfiguration() + contentConfiguration.text = itemIdentifier.text + contentConfiguration.isEnabled = isTesting + contentConfiguration.primaryAction = UIAction { [weak self] _ in + self?.onCancelTest?() + } + + cell.contentConfiguration = contentConfiguration + } + + private func configureTestingStatus(_ cell: UITableViewCell, itemIdentifier: MethodSettingsItemIdentifier) { + let viewStatus = subject.value.testingStatus.viewStatus + + var contentConfiguration = MethodTestingStatusCellContentConfiguration() + contentConfiguration.status = viewStatus + contentConfiguration.detailText = viewStatus == .reachable + ? NSLocalizedString( + "METHOD_SETTINGS_SAVING_CHANGES", + tableName: "APIAccess", + value: "Saving changes...", + comment: "" + ) + : nil + + cell.contentConfiguration = contentConfiguration + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/MethodSettings/MethodSettingsDataSourceConfiguration.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/MethodSettings/MethodSettingsDataSourceConfiguration.swift new file mode 100644 index 000000000000..c026f607e883 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/MethodSettings/MethodSettingsDataSourceConfiguration.swift @@ -0,0 +1,120 @@ +// +// MethodSettingsDataSourceConfiguration.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-01-19. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +class MethodSettingsDataSourceConfiguration { + private let dataSource: UITableViewDiffableDataSource< + MethodSettingsSectionIdentifier, + MethodSettingsItemIdentifier + >? + + init( + dataSource: UITableViewDiffableDataSource + ) { + self.dataSource = dataSource + } + + func updateDataSource( + previousValue: AccessMethodViewModel?, + newValue: AccessMethodViewModel, + previousValidationError: [AccessMethodFieldValidationError], + newValidationError: [AccessMethodFieldValidationError], + animated: Bool, + completion: (() -> Void)? = nil + ) { + var snapshot = NSDiffableDataSourceSnapshot() + + // Add name field for user-defined access methods. + if !newValue.method.isPermanent { + snapshot.appendSections([.name]) + snapshot.appendItems([.name], toSection: .name) + } + + snapshot.appendSections([.protocol]) + snapshot.appendItems([.protocol], toSection: .protocol) + // Reconfigure protocol cell on change. + if let previousValue, previousValue.method != newValue.method { + snapshot.reconfigureOrReloadItems([.protocol]) + } + + // Add proxy configuration section if the access method is configurable. + if newValue.method.hasProxyConfiguration { + snapshot.appendSections([.proxyConfiguration]) + } + + switch newValue.method { + case .direct, .bridges: + break + + case .shadowsocks: + snapshot.appendItems(MethodSettingsItemIdentifier.allShadowsocksItems, toSection: .proxyConfiguration) + // Reconfigure cipher cell on change. + if let previousValue, previousValue.shadowsocks.cipher != newValue.shadowsocks.cipher { + snapshot.reconfigureOrReloadItems([.proxyConfiguration(.shadowsocks(.cipher))]) + } + + // Reconfigure the proxy configuration cell if validation error changed. + if previousValidationError != newValidationError { + snapshot.reconfigureOrReloadItems(MethodSettingsItemIdentifier.allShadowsocksItems) + } + case .socks5: + snapshot.appendItems( + MethodSettingsItemIdentifier.allSocksItems(authenticate: newValue.socks.authenticate), + toSection: .proxyConfiguration + ) + + // Reconfigure the proxy configuration cell if validation error changed. + if previousValidationError != newValidationError { + snapshot.reconfigureOrReloadItems( + MethodSettingsItemIdentifier.allSocksItems(authenticate: newValue.socks.authenticate) + ) + } + } + + snapshot.appendSections([.validationError]) + snapshot.appendItems([.validationError], toSection: .validationError) + + snapshot.appendSections([.testingStatus]) + snapshot.appendSections([.cancelTest]) + + // Add test status below the test button. + if newValue.testingStatus != .initial { + snapshot.appendItems([.testingStatus], toSection: .testingStatus) + + // Show cancel test button below test status. + if newValue.testingStatus == .inProgress { + snapshot.appendItems([.cancelTest], toSection: .cancelTest) + } + } + + if let previousValue, previousValue.testingStatus != newValue.testingStatus { + snapshot.reconfigureOrReloadItems(snapshot.itemIdentifiers) + } + + dataSource?.apply(snapshot, animatingDifferences: animated, completion: completion) + } + + func updateDataSourceWithContentValidationErrors(viewModel: AccessMethodViewModel) { + guard var snapshot = dataSource?.snapshot() else { + return + } + + let itemsToReload: [MethodSettingsItemIdentifier] = switch viewModel.method { + case .direct, .bridges: + [] + case .shadowsocks: + MethodSettingsItemIdentifier.allShadowsocksItems + case .socks5: + MethodSettingsItemIdentifier.allSocksItems(authenticate: viewModel.socks.authenticate) + } + + snapshot.reconfigureOrReloadItems(itemsToReload + [.validationError]) + dataSource?.apply(snapshot, animatingDifferences: false) + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/MethodSettings/MethodSettingsItemIdentifier.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/MethodSettings/MethodSettingsItemIdentifier.swift new file mode 100644 index 000000000000..b24b94ad3240 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/MethodSettings/MethodSettingsItemIdentifier.swift @@ -0,0 +1,114 @@ +// +// MethodSettingsItemIdentifier.swift +// MullvadVPN +// +// Created by pronebird on 22/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +enum MethodSettingsItemIdentifier: Hashable { + case name + case `protocol` + case proxyConfiguration(ProxyProtocolConfigurationItemIdentifier) + case validationError + case testingStatus + case cancelTest + + /// Returns all shadowsocks items wrapped into `ProxyConfigurationItemIdentifier.proxyConfiguration`. + static var allShadowsocksItems: [MethodSettingsItemIdentifier] { + ShadowsocksItemIdentifier.allCases.map { .proxyConfiguration(.shadowsocks($0)) } + } + + /// Returns all socks items wrapped into `ProxyConfigurationItemIdentifier.proxyConfiguration`. + static func allSocksItems(authenticate: Bool) -> [MethodSettingsItemIdentifier] { + SocksItemIdentifier.allCases(authenticate: authenticate).map { .proxyConfiguration(.socks($0)) } + } + + /// Cell identifiers for the item identifier. + var cellIdentifier: AccessMethodCellReuseIdentifier { + switch self { + case .name: + .textInput + case .protocol: + .textWithDisclosure + case let .proxyConfiguration(itemIdentifier): + itemIdentifier.cellIdentifier + case .validationError: + .validationError + case .testingStatus: + .testingStatus + case .cancelTest: + .button + } + } + + /// Returns `true` if the cell background should be made transparent. + var isClearBackground: Bool { + switch self { + case .validationError, .cancelTest, .testingStatus: + return true + case .name, .protocol, .proxyConfiguration: + return false + } + } + + /// Indicates whether cell representing the item should be selectable. + var isSelectable: Bool { + switch self { + case .name, .validationError, .testingStatus, .cancelTest: + false + case .protocol: + true + case let .proxyConfiguration(itemIdentifier): + itemIdentifier.isSelectable + } + } + + /// The text label for the corresponding cell. + var text: String? { + switch self { + case .name: + NSLocalizedString("NAME", tableName: "APIAccess", value: "Name", comment: "") + case .protocol: + NSLocalizedString("TYPE", tableName: "APIAccess", value: "Type", comment: "") + case .proxyConfiguration, .validationError: + nil + case .cancelTest: + NSLocalizedString("CANCEL_TEST", tableName: "APIAccess", value: "Cancel", comment: "") + case .testingStatus: + nil + } + } + + static func fromFieldValidationErrors( + _ errors: [AccessMethodFieldValidationError], + selectedMethod: AccessMethodKind + ) -> [MethodSettingsItemIdentifier] { + switch selectedMethod { + case .direct, .bridges: + [] + case .shadowsocks: + errors.compactMap { error in + switch error.field { + case .name: .name + case .server: .proxyConfiguration(.shadowsocks(.server)) + case .port: .proxyConfiguration(.shadowsocks(.port)) + case .username: nil + case .password: .proxyConfiguration(.shadowsocks(.password)) + } + } + case .socks5: + errors.map { error in + switch error.field { + case .name: .name + case .server: .proxyConfiguration(.socks(.server)) + case .port: .proxyConfiguration(.socks(.port)) + case .username: .proxyConfiguration(.socks(.username)) + case .password: .proxyConfiguration(.socks(.password)) + } + } + } + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/ProxyConfigurationSectionIdentifier.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/MethodSettings/MethodSettingsSectionIdentifier.swift similarity index 55% rename from ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/ProxyConfigurationSectionIdentifier.swift rename to ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/MethodSettings/MethodSettingsSectionIdentifier.swift index 7c02cb33ad7a..cbb4b3313f49 100644 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/ProxyConfigurationSectionIdentifier.swift +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/MethodSettings/MethodSettingsSectionIdentifier.swift @@ -1,5 +1,5 @@ // -// ProxyConfigurationSectionIdentifier.swift +// MethodSettingsSectionIdentifier.swift // MullvadVPN // // Created by pronebird on 21/11/2023. @@ -8,24 +8,23 @@ import Foundation -enum ProxyConfigurationSectionIdentifier: Hashable { +enum MethodSettingsSectionIdentifier: Hashable { + case name case `protocol` case proxyConfiguration + case validationError + case testingStatus + case cancelTest var sectionName: String? { switch self { - case .protocol: - NSLocalizedString( - "PROTOCOL_SECTION_TITLE", - tableName: "APIAccess", - value: "Protocol", - comment: "" - ) + case .name, .protocol, .validationError, .testingStatus, .cancelTest: + nil case .proxyConfiguration: NSLocalizedString( "HOST_CONFIG_SECTION_TITLE", tableName: "APIAccess", - value: "Host configuration", + value: "Server details", comment: "" ) } diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/MethodSettings/MethodSettingsValidationErrorContentConfiguration.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/MethodSettings/MethodSettingsValidationErrorContentConfiguration.swift new file mode 100644 index 000000000000..74aa8f87a4c5 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/MethodSettings/MethodSettingsValidationErrorContentConfiguration.swift @@ -0,0 +1,26 @@ +// +// MethodSettingsValidationErrorContentConfiguration.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-01-12. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +/// Content configuration for presenting the access method testing progress. +struct MethodSettingsValidationErrorContentConfiguration: UIContentConfiguration, Equatable { + /// Field validation errors. + var fieldErrors: [AccessMethodFieldValidationError] = [] + + /// Layout margins. + var directionalLayoutMargins: NSDirectionalEdgeInsets = UIMetrics.SettingsCell.apiAccessInsetLayoutMargins + + func makeContentView() -> UIView & UIContentView { + return MethodSettingsValidationErrorContentView(configuration: self) + } + + func updated(for state: UIConfigurationState) -> Self { + return self + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/MethodSettings/MethodSettingsValidationErrorContentView.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/MethodSettings/MethodSettingsValidationErrorContentView.swift new file mode 100644 index 000000000000..38b3f0cd3a4e --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/MethodSettings/MethodSettingsValidationErrorContentView.swift @@ -0,0 +1,91 @@ +// +// MethodSettingsValidationErrorContentView.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-01-12. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +/// Content view presenting the access method validation errors. +class MethodSettingsValidationErrorContentView: UIView, UIContentView { + let contentView = UIStackView() + + var icon: UIImageView { + let view = UIImageView(image: UIImage(resource: .iconAlert).withTintColor(.dangerColor)) + view.heightAnchor.constraint(equalToConstant: 14).isActive = true + view.widthAnchor.constraint(equalTo: view.heightAnchor, multiplier: 1).isActive = true + return view + } + + var configuration: UIContentConfiguration { + get { + actualConfiguration + } + set { + guard let newConfiguration = newValue as? MethodSettingsValidationErrorContentConfiguration else { return } + + let previousConfiguration = actualConfiguration + actualConfiguration = newConfiguration + + configureSubviews(previousConfiguration: previousConfiguration) + } + } + + private var actualConfiguration: MethodSettingsValidationErrorContentConfiguration + + func supports(_ configuration: UIContentConfiguration) -> Bool { + configuration is MethodSettingsValidationErrorContentConfiguration + } + + init(configuration: MethodSettingsValidationErrorContentConfiguration) { + actualConfiguration = configuration + + super.init(frame: CGRect(x: 0, y: 0, width: 100, height: 44)) + + addSubviews() + configureSubviews() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func addSubviews() { + contentView.axis = .vertical + contentView.spacing = 6 + + addConstrainedSubviews([contentView]) { + contentView.pinEdgesToSuperviewMargins() + } + } + + private func configureSubviews(previousConfiguration: MethodSettingsValidationErrorContentConfiguration? = nil) { + guard actualConfiguration != previousConfiguration else { return } + + configureLayoutMargins() + + contentView.arrangedSubviews.forEach { view in + view.removeFromSuperview() + } + + actualConfiguration.fieldErrors.forEach { error in + let label = UILabel() + label.text = error.errorDescription + label.numberOfLines = 0 + label.font = .systemFont(ofSize: 13) + label.textColor = .white.withAlphaComponent(0.6) + + let stackView = UIStackView(arrangedSubviews: [icon, label]) + stackView.alignment = .top + stackView.spacing = 6 + + contentView.addArrangedSubview(stackView) + } + } + + private func configureLayoutMargins() { + directionalLayoutMargins = actualConfiguration.directionalLayoutMargins + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/MethodSettings/MethodSettingsViewController.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/MethodSettings/MethodSettingsViewController.swift new file mode 100644 index 000000000000..78fc98bc73f4 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/MethodSettings/MethodSettingsViewController.swift @@ -0,0 +1,351 @@ +// +// MethodSettingsViewController.swift +// MullvadVPN +// +// Created by pronebird on 21/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Combine +import struct MullvadTypes.Duration +import UIKit + +/// The view controller providing the interface for editing method settings +/// and testing the proxy configuration. +class MethodSettingsViewController: UITableViewController { + typealias MethodSettingsDataSource = UITableViewDiffableDataSource< + MethodSettingsSectionIdentifier, + MethodSettingsItemIdentifier + > + + private let subject: CurrentValueSubject + private let interactor: EditAccessMethodInteractorProtocol + private var cancellables = Set() + private var alertPresenter: AlertPresenter + private var inputValidationErrors: [AccessMethodFieldValidationError] = [] + private var contentValidationErrors: [AccessMethodFieldValidationError] = [] + private var dataSource: MethodSettingsDataSource? + + private lazy var cellConfiguration: MethodSettingsCellConfiguration = { + var configuration = MethodSettingsCellConfiguration(tableView: tableView, subject: subject) + configuration.onCancelTest = { [weak self] in + self?.onCancelTest() + } + return configuration + }() + + private lazy var dataSourceConfiguration: MethodSettingsDataSourceConfiguration? = { + return dataSource.flatMap { dataSource in + MethodSettingsDataSourceConfiguration(dataSource: dataSource) + } + }() + + private var isTesting: Bool { + subject.value.testingStatus == .inProgress + } + + lazy var saveBarButton: UIBarButtonItem = { + let barButtonItem = UIBarButtonItem( + title: NSLocalizedString("SAVE_NAVIGATION_BUTTON", tableName: "APIAccess", value: "Save", comment: ""), + primaryAction: UIAction { [weak self] _ in + self?.onTest() + } + ) + barButtonItem.style = .done + return barButtonItem + }() + + weak var delegate: MethodSettingsViewControllerDelegate? + + init( + subject: CurrentValueSubject, + interactor: EditAccessMethodInteractorProtocol, + alertPresenter: AlertPresenter + ) { + self.subject = subject + self.interactor = interactor + self.alertPresenter = alertPresenter + + super.init(style: .insetGrouped) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + view.directionalLayoutMargins = UIMetrics.contentLayoutMargins + view.backgroundColor = .secondaryColor + + navigationItem.rightBarButtonItem = saveBarButton + isModalInPresentation = true + + configureTableView() + configureDataSource() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + inputValidationErrors.removeAll() + contentValidationErrors.removeAll() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + interactor.cancelProxyConfigurationTest() + } + + // MARK: - UITableViewDelegate + + override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + guard let itemIdentifier = dataSource?.itemIdentifier(for: indexPath) else { return 0 } + + switch itemIdentifier { + case .name, .protocol, .proxyConfiguration, .cancelTest: + return UIMetrics.SettingsCell.apiAccessCellHeight + case .validationError: + return contentValidationErrors.isEmpty + ? UIMetrics.SettingsCell.apiAccessCellHeight + : UITableView.automaticDimension + case .testingStatus: + return UITableView.automaticDimension + } + } + + override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + guard let sectionIdentifier = dataSource?.snapshot().sectionIdentifiers[section] else { return nil } + + guard let headerView = tableView + .dequeueReusableView(withIdentifier: AccessMethodHeaderFooterReuseIdentifier.primary) + else { return nil } + + var contentConfiguration = UIListContentConfiguration.mullvadGroupedHeader(tableStyle: tableView.style) + contentConfiguration.text = sectionIdentifier.sectionName + + headerView.contentConfiguration = contentConfiguration + + return headerView + } + + override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + guard let sectionIdentifier = dataSource?.snapshot().sectionIdentifiers[section] else { return 0 } + + switch sectionIdentifier { + case .name, .protocol, .proxyConfiguration, .testingStatus: + return UITableView.automaticDimension + case .validationError, .cancelTest: + return 0 + } + } + + override func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { + return nil + } + + override func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { + guard let sectionIdentifier = dataSource?.snapshot().sectionIdentifiers[section] else { return 0 } + + switch sectionIdentifier { + case .name, .protocol, .cancelTest: + return UITableView.automaticDimension + case .proxyConfiguration, .validationError, .testingStatus: + return 0 + } + } + + override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { + guard !isTesting, let itemIdentifier = dataSource?.itemIdentifier(for: indexPath), + itemIdentifier.isSelectable else { return nil } + + return indexPath + } + + override func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool { + guard let itemIdentifier = dataSource?.itemIdentifier(for: indexPath) else { return false } + + return itemIdentifier.isSelectable + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + let itemIdentifier = dataSource?.itemIdentifier(for: indexPath) + + switch itemIdentifier { + case .protocol: + showProtocolSelector() + case .proxyConfiguration(.shadowsocks(.cipher)): + showShadowsocksCipher() + default: + break + } + } + + // MARK: - Pickers handling + + private func showProtocolSelector() { + view.endEditing(false) + delegate?.controllerShouldShowProtocolPicker(self) + } + + private func showShadowsocksCipher() { + view.endEditing(false) + delegate?.controllerShouldShowShadowsocksCipherPicker(self) + } + + // MARK: - Data source handling + + private func configureDataSource() { + tableView.registerReusableViews(from: AccessMethodCellReuseIdentifier.self) + tableView.registerReusableViews(from: AccessMethodHeaderFooterReuseIdentifier.self) + + dataSource = UITableViewDiffableDataSource( + tableView: tableView, + cellProvider: { [weak self] _, indexPath, itemIdentifier in + guard let self else { return nil } + + return cellConfiguration.dequeueCell( + at: indexPath, + for: itemIdentifier, + contentValidationErrors: contentValidationErrors + ) + } + ) + + subject.withPreviousValue() + .sink { [weak self] previousValue, newValue in + self?.viewModelDidChange(previousValue: previousValue, newValue: newValue) + } + .store(in: &cancellables) + } + + private func viewModelDidChange(previousValue: AccessMethodViewModel?, newValue: AccessMethodViewModel) { + let animated = view.window != nil + let previousValidationError = inputValidationErrors + + validateInput() + + dataSourceConfiguration?.updateDataSource( + previousValue: previousValue, + newValue: newValue, + previousValidationError: previousValidationError, + newValidationError: inputValidationErrors, + animated: animated + ) { [weak self] in + if let indexPathToScrollTo = self?.dataSource?.indexPath(for: .testingStatus) { + self?.tableView.scrollToRow(at: indexPathToScrollTo, at: .top, animated: true) + } + } + } + + private func configureTableView() { + tableView.delegate = self + tableView.backgroundColor = .secondaryColor + tableView.separatorColor = .secondaryColor + tableView.separatorInset.left = UIMetrics.SettingsCell.apiAccessInsetLayoutMargins.leading + } + + // MARK: - Misc + + private func validateInput() { + let validationResult = Result { try subject.value.validate() } + let validationError = validationResult.error as? AccessMethodValidationError + + // Only look for empty values for input validation. + inputValidationErrors = validationError?.fieldErrors.filter { error in + error.kind == .emptyValue + } ?? [] + + saveBarButton.isEnabled = !isTesting && inputValidationErrors.isEmpty + } + + private func validateContent() { + let validationResult = Result { try subject.value.validate() } + let validationError = validationResult.error as? AccessMethodValidationError + + // Only look for format errors for test(save validation. + contentValidationErrors = validationError?.fieldErrors.filter { error in + error.kind != .emptyValue + } ?? [] + } + + private func onSave(transitionDelay: Duration = .zero) { + interactor.saveAccessMethod() + + DispatchQueue.main.asyncAfter(deadline: .now() + transitionDelay.timeInterval) { [weak self] in + guard + let self, + let accessMethod = try? subject.value.intoPersistentAccessMethod() + else { return } + + delegate?.accessMethodDidSave(accessMethod) + } + } + + private func onTest() { + validateContent() + + guard contentValidationErrors.isEmpty else { + dataSourceConfiguration?.updateDataSourceWithContentValidationErrors(viewModel: subject.value) + return + } + + view.endEditing(true) + saveBarButton.isEnabled = false + + interactor.startProxyConfigurationTest { [weak self] _ in + self?.onTestCompleted() + } + } + + private func onTestCompleted() { + switch subject.value.testingStatus { + case .initial, .inProgress: + break + + case .failed: + let presentation = AlertPresentation( + id: "api-access-methods-testing-status-failed-alert", + icon: .warning, + message: NSLocalizedString( + "METHOD_SETTINGS_SAVE_PROMPT", + tableName: "APIAccess", + value: "API could not be reached, save anyway?", + comment: "" + ), + buttons: [ + AlertAction( + title: NSLocalizedString( + "METHOD_SETTINGS_SAVE_BUTTON", + tableName: "APIAccess", + value: "Save anyway", + comment: "" + ), + style: .default, + handler: { [weak self] in + self?.onSave() + } + ), + AlertAction( + title: NSLocalizedString( + "METHOD_SETTINGS_BACK_BUTTON", + tableName: "APIAccess", + value: "Back to editing", + comment: "" + ), + style: .default + ), + ] + ) + + alertPresenter.showAlert(presentation: presentation, animated: true) + case .succeeded: + onSave(transitionDelay: .seconds(1)) + } + } + + private func onCancelTest() { + interactor.cancelProxyConfigurationTest() + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/MethodSettings/MethodSettingsViewControllerDelegate.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/MethodSettings/MethodSettingsViewControllerDelegate.swift new file mode 100644 index 000000000000..a60590a26965 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/MethodSettings/MethodSettingsViewControllerDelegate.swift @@ -0,0 +1,14 @@ +// +// MethodSettingsViewControllerDelegate.swift +// MullvadVPN +// +// Created by pronebird on 23/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +protocol MethodSettingsViewControllerDelegate: AnyObject, AccessMethodEditing { + func controllerShouldShowProtocolPicker(_ controller: MethodSettingsViewController) + func controllerShouldShowShadowsocksCipherPicker(_ controller: MethodSettingsViewController) +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Sheet/Content view/AccessMethodActionSheetContentConfiguration.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/MethodSettings/MethodTestingStatusCellContentConfiguration.swift similarity index 66% rename from ios/MullvadVPN/Coordinators/Settings/APIAccess/Sheet/Content view/AccessMethodActionSheetContentConfiguration.swift rename to ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/MethodSettings/MethodTestingStatusCellContentConfiguration.swift index 5fb2de4a9abf..d91990ed7961 100644 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Sheet/Content view/AccessMethodActionSheetContentConfiguration.swift +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/MethodSettings/MethodTestingStatusCellContentConfiguration.swift @@ -1,15 +1,15 @@ // -// AccessMethodActionSheetContentConfiguration.swift +// MethodTestingStatusCellContentConfiguration.swift // MullvadVPN // -// Created by pronebird on 28/11/2023. +// Created by pronebird on 27/11/2023. // Copyright © 2023 Mullvad VPN AB. All rights reserved. // import UIKit -/// Sheet content view configuration. -struct AccessMethodActionSheetContentConfiguration: Equatable { +/// Content configuration for presenting the access method testing progress. +struct MethodTestingStatusCellContentConfiguration: UIContentConfiguration, Equatable { /// The status of access method testing. enum Status: Equatable { /// API Is reachable. @@ -27,9 +27,20 @@ struct AccessMethodActionSheetContentConfiguration: Equatable { /// Detail text displayed below the status when set. var detailText: String? + + /// Layout margins. + var directionalLayoutMargins: NSDirectionalEdgeInsets = UIMetrics.SettingsCell.apiAccessInsetLayoutMargins + + func makeContentView() -> UIView & UIContentView { + return MethodTestingStatusCellContentView(configuration: self) + } + + func updated(for state: UIConfigurationState) -> Self { + return self + } } -extension AccessMethodActionSheetContentConfiguration.Status { +extension MethodTestingStatusCellContentConfiguration.Status { /// The text label descirbing the status of testing and suitable for user presentation. var text: String { switch self { diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Sheet/Content view/AccessMethodActionSheetContentView.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/MethodSettings/MethodTestingStatusCellContentView.swift similarity index 68% rename from ios/MullvadVPN/Coordinators/Settings/APIAccess/Sheet/Content view/AccessMethodActionSheetContentView.swift rename to ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/MethodSettings/MethodTestingStatusCellContentView.swift index 45579d680cd6..b8c9d5f205c1 100644 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Sheet/Content view/AccessMethodActionSheetContentView.swift +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/MethodSettings/MethodTestingStatusCellContentView.swift @@ -1,5 +1,5 @@ // -// AccessMethodActionSheetContentView.swift +// MethodTestingStatusContentCell.swift // MullvadVPN // // Created by pronebird on 16/11/2023. @@ -8,16 +8,11 @@ import UIKit -/// The sheet content view implementing a layout with an activity indicator or status indicator and primary text label, with detail label below. -class AccessMethodActionSheetContentView: UIView { - var configuration = AccessMethodActionSheetContentConfiguration() { - didSet { - updateView() - } - } - +/// Content view presenting the access method testing progress. +class MethodTestingStatusCellContentView: UIView, UIContentView { private let progressView = SpinnerActivityIndicatorView(style: .custom) private let progressContainer = UIView() + private let containerView = UIView() private let statusIndicator: UIView = { let view = UIView() @@ -58,20 +53,40 @@ class AccessMethodActionSheetContentView: UIView { return stackView }() - private let containerView = UIView() + var configuration: UIContentConfiguration { + get { + actualConfiguration + } + set { + guard let newConfiguration = newValue as? MethodTestingStatusCellContentConfiguration else { return } + + let previousConfiguration = actualConfiguration + actualConfiguration = newConfiguration + + configureSubviews(previousConfiguration: previousConfiguration) + } + } + + private var actualConfiguration: MethodTestingStatusCellContentConfiguration + + func supports(_ configuration: UIContentConfiguration) -> Bool { + configuration is MethodTestingStatusCellContentConfiguration + } + + init(configuration: MethodTestingStatusCellContentConfiguration) { + actualConfiguration = configuration - init() { super.init(frame: CGRect(x: 0, y: 0, width: 100, height: 44)) - setupView() - updateView() + addSubviews() + configureSubviews() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - private func setupView() { + private func addSubviews() { NSLayoutConstraint.activate([ progressView.widthAnchor.constraint(equalToConstant: 30), progressView.heightAnchor.constraint(equalToConstant: 30), @@ -80,7 +95,7 @@ class AccessMethodActionSheetContentView: UIView { progressContainer.heightAnchor.constraint(equalToConstant: 20), statusIndicator.widthAnchor.constraint(equalToConstant: 20), - statusIndicator.heightAnchor.constraint(equalToConstant: 20), + statusIndicator.heightAnchor.constraint(equalToConstant: 20).withPriority(.defaultHigh), ]) containerView.addConstrainedSubviews([horizontalStackView]) { @@ -94,17 +109,16 @@ class AccessMethodActionSheetContentView: UIView { } addConstrainedSubviews([verticalStackView]) { - verticalStackView.pinEdgesToSuperview() + verticalStackView.pinEdgesToSuperviewMargins() } } - private func updateView() { - textLabel.text = configuration.status.text - detailLabel.text = configuration.detailText - statusIndicator.backgroundColor = configuration.status.statusColor + private func configureSubviews(previousConfiguration: MethodTestingStatusCellContentConfiguration? = nil) { + configureLayoutMargins() - // Hide detail label when empty to prevent extra margin between subviews in the stack. - detailLabel.isHidden = configuration.detailText?.isEmpty ?? true + textLabel.text = actualConfiguration.status.text + detailLabel.text = actualConfiguration.detailText + statusIndicator.backgroundColor = actualConfiguration.status.statusColor // Remove the first view in the horizontal stack which is either a status indicator or progress. horizontalStackView.arrangedSubviews.first.map { view in @@ -113,7 +127,7 @@ class AccessMethodActionSheetContentView: UIView { } // Reconfigure the horizontal stack by adding the status indicator or progress first. - switch configuration.status { + switch actualConfiguration.status { case .reachable, .unreachable: horizontalStackView.insertArrangedSubview(statusIndicator, at: 0) @@ -127,4 +141,8 @@ class AccessMethodActionSheetContentView: UIView { horizontalStackView.addArrangedSubview(textLabel) } } + + private func configureLayoutMargins() { + directionalLayoutMargins = actualConfiguration.directionalLayoutMargins + } } diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/ProxyConfiguration/ProxyConfigurationItemIdentifier.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/ProxyConfiguration/ProxyConfigurationItemIdentifier.swift deleted file mode 100644 index 68f061584e1d..000000000000 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/ProxyConfiguration/ProxyConfigurationItemIdentifier.swift +++ /dev/null @@ -1,54 +0,0 @@ -// -// ProxyConfigurationItemIdentifier.swift -// MullvadVPN -// -// Created by pronebird on 22/11/2023. -// Copyright © 2023 Mullvad VPN AB. All rights reserved. -// - -import Foundation - -enum ProxyConfigurationItemIdentifier: Hashable { - case `protocol` - case proxyConfiguration(ProxyProtocolConfigurationItemIdentifier) - - /// Returns all shadowsocks items wrapped into `ProxyConfigurationItemIdentifier.proxyConfiguration`. - static var allShadowsocksItems: [ProxyConfigurationItemIdentifier] { - ShadowsocksItemIdentifier.allCases.map { .proxyConfiguration(.shadowsocks($0)) } - } - - /// Returns all socks items wrapped into `ProxyConfigurationItemIdentifier.proxyConfiguration`. - static func allSocksItems(authenticate: Bool) -> [ProxyConfigurationItemIdentifier] { - SocksItemIdentifier.allCases(authenticate: authenticate).map { .proxyConfiguration(.socks($0)) } - } - - /// Cell identifiers for the item identifier. - var cellIdentifier: AccessMethodCellReuseIdentifier { - switch self { - case .protocol: - .textWithDisclosure - case let .proxyConfiguration(itemIdentifier): - itemIdentifier.cellIdentifier - } - } - - /// Indicates whether cell representing the item should be selectable. - var isSelectable: Bool { - switch self { - case .protocol: - true - case let .proxyConfiguration(itemIdentifier): - itemIdentifier.isSelectable - } - } - - /// The text label for the corresponding cell. - var text: String? { - switch self { - case .protocol: - NSLocalizedString("TYPE", tableName: "APIAccess", value: "Type", comment: "") - case .proxyConfiguration: - nil - } - } -} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/ProxyConfiguration/ProxyConfigurationSectionIdentifier.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/ProxyConfiguration/ProxyConfigurationSectionIdentifier.swift deleted file mode 100644 index 7c02cb33ad7a..000000000000 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/ProxyConfiguration/ProxyConfigurationSectionIdentifier.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// ProxyConfigurationSectionIdentifier.swift -// MullvadVPN -// -// Created by pronebird on 21/11/2023. -// Copyright © 2023 Mullvad VPN AB. All rights reserved. -// - -import Foundation - -enum ProxyConfigurationSectionIdentifier: Hashable { - case `protocol` - case proxyConfiguration - - var sectionName: String? { - switch self { - case .protocol: - NSLocalizedString( - "PROTOCOL_SECTION_TITLE", - tableName: "APIAccess", - value: "Protocol", - comment: "" - ) - case .proxyConfiguration: - NSLocalizedString( - "HOST_CONFIG_SECTION_TITLE", - tableName: "APIAccess", - value: "Host configuration", - comment: "" - ) - } - } -} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/ProxyConfiguration/ProxyConfigurationViewController.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/ProxyConfiguration/ProxyConfigurationViewController.swift deleted file mode 100644 index 63ce73365e66..000000000000 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/ProxyConfiguration/ProxyConfigurationViewController.swift +++ /dev/null @@ -1,313 +0,0 @@ -// -// ProxyConfigurationViewController.swift -// MullvadVPN -// -// Created by pronebird on 21/11/2023. -// Copyright © 2023 Mullvad VPN AB. All rights reserved. -// - -import Combine -import UIKit - -/// The view controller providing the interface for editing and testing the proxy configuration. -class ProxyConfigurationViewController: UIViewController, UITableViewDelegate { - private let subject: CurrentValueSubject - private let interactor: ProxyConfigurationInteractorProtocol - private var cancellables = Set() - - private var dataSource: UITableViewDiffableDataSource< - ProxyConfigurationSectionIdentifier, - ProxyConfigurationItemIdentifier - >? - private lazy var testBarButton: UIBarButtonItem = { - let barButtonItem = UIBarButtonItem( - title: NSLocalizedString("TEST_NAVIGATION_BUTTON", tableName: "APIAccess", value: "Test", comment: ""), - primaryAction: UIAction { [weak self] _ in - self?.onTest() - } - ) - barButtonItem.style = .done - return barButtonItem - }() - - private let contentController = UITableViewController(style: .insetGrouped) - private var tableView: UITableView { - contentController.tableView - } - - private lazy var sheetPresentation: AccessMethodActionSheetPresentation = { - let sheetPresentation = AccessMethodActionSheetPresentation() - sheetPresentation.delegate = self - return sheetPresentation - }() - - weak var delegate: ProxyConfigurationViewControllerDelegate? - - init(subject: CurrentValueSubject, interactor: ProxyConfigurationInteractorProtocol) { - self.subject = subject - 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.directionalLayoutMargins = UIMetrics.contentLayoutMargins - view.backgroundColor = .secondaryColor - - configureTableView() - configureNavigationItem() - configureDataSource() - } - - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - - updateTableSafeAreaInsets() - } - - // MARK: - UITableViewDelegate - - func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - guard let sectionIdentifier = dataSource?.snapshot().sectionIdentifiers[section] else { return nil } - - guard let headerView = tableView - .dequeueReusableView(withIdentifier: AccessMethodHeaderFooterReuseIdentifier.primary) - else { return nil } - - var contentConfiguration = UIListContentConfiguration.mullvadGroupedHeader() - contentConfiguration.text = sectionIdentifier.sectionName - - headerView.contentConfiguration = contentConfiguration - - return headerView - } - - func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { - guard let itemIdentifier = dataSource?.itemIdentifier(for: indexPath), - itemIdentifier.isSelectable else { return nil } - - return indexPath - } - - func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool { - guard let itemIdentifier = dataSource?.itemIdentifier(for: indexPath) else { return false } - - return itemIdentifier.isSelectable - } - - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - let itemIdentifier = dataSource?.itemIdentifier(for: indexPath) - - switch itemIdentifier { - case .protocol: - showProtocolSelector() - case .proxyConfiguration(.shadowsocks(.cipher)): - showShadowsocksCipher() - default: - break - } - } - - // MARK: - Pickers handling - - private func showProtocolSelector() { - view.endEditing(false) - delegate?.controllerShouldShowProtocolPicker(self) - } - - private func showShadowsocksCipher() { - view.endEditing(false) - delegate?.controllerShouldShowShadowsocksCipherPicker(self) - } - - // MARK: - Cell configuration - - private func dequeueCell(at indexPath: IndexPath, for itemIdentifier: ProxyConfigurationItemIdentifier) - -> UITableViewCell { - let cell = tableView.dequeueReusableView(withIdentifier: itemIdentifier.cellIdentifier, for: indexPath) - - if let cell = cell as? DynamicBackgroundConfiguration { - cell.setAutoAdaptingBackgroundConfiguration(.mullvadListGroupedCell(), selectionType: .dimmed) - } - - switch itemIdentifier { - case .protocol: - configureProtocol(cell, itemIdentifier: itemIdentifier) - case let .proxyConfiguration(proxyItemIdentifier): - configureProxy(cell, itemIdentifier: proxyItemIdentifier) - } - - return cell - } - - private func configureProxy(_ cell: UITableViewCell, itemIdentifier: ProxyProtocolConfigurationItemIdentifier) { - switch itemIdentifier { - case let .socks(socksItemIdentifier): - let section = SocksSectionHandler(tableStyle: tableView.style, subject: subject) - section.configure(cell, itemIdentifier: socksItemIdentifier) - - case let .shadowsocks(shadowsocksItemIdentifier): - let section = ShadowsocksSectionHandler(tableStyle: tableView.style, subject: subject) - section.configure(cell, itemIdentifier: shadowsocksItemIdentifier) - } - } - - private func configureProtocol(_ cell: UITableViewCell, itemIdentifier: ProxyConfigurationItemIdentifier) { - var contentConfiguration = UIListContentConfiguration.mullvadValueCell(tableStyle: tableView.style) - contentConfiguration.text = itemIdentifier.text - contentConfiguration.secondaryText = subject.value.method.localizedDescription - cell.contentConfiguration = contentConfiguration - - if let cell = cell as? CustomCellDisclosureHandling { - cell.disclosureType = .chevron - } - } - - // MARK: - Data source handling - - private func configureDataSource() { - tableView.registerReusableViews(from: AccessMethodCellReuseIdentifier.self) - tableView.registerReusableViews(from: AccessMethodHeaderFooterReuseIdentifier.self) - - dataSource = UITableViewDiffableDataSource( - tableView: tableView, - cellProvider: { [weak self] _, indexPath, itemIdentifier in - self?.dequeueCell(at: indexPath, for: itemIdentifier) - } - ) - - subject.withPreviousValue() - .sink { [weak self] previousValue, newValue in - self?.viewModelDidChange(previousValue: previousValue, newValue: newValue) - } - .store(in: &cancellables) - } - - private func viewModelDidChange(previousValue: AccessMethodViewModel?, newValue: AccessMethodViewModel) { - let animated = view.window != nil - - updateDataSource(previousValue: previousValue, newValue: newValue, animated: animated) - updateSheet(previousValue: previousValue, newValue: newValue, animated: animated) - validate() - } - - private func updateSheet(previousValue: AccessMethodViewModel?, newValue: AccessMethodViewModel, animated: Bool) { - guard previousValue?.testingStatus != newValue.testingStatus else { return } - - switch newValue.testingStatus { - case .initial: - sheetPresentation.hide(animated: animated) - - case .inProgress, .failed, .succeeded: - var presentationConfiguration = AccessMethodActionSheetPresentationConfiguration() - presentationConfiguration.dimsBackground = newValue.testingStatus == .inProgress - presentationConfiguration.sheetConfiguration.context = .proxyConfiguration - presentationConfiguration.sheetConfiguration.contentConfiguration.status = newValue.testingStatus - .sheetStatus - sheetPresentation.configuration = presentationConfiguration - - sheetPresentation.show(in: view, animated: animated) - } - } - - private func updateDataSource( - previousValue: AccessMethodViewModel?, - newValue: AccessMethodViewModel, - animated: Bool - ) { - var snapshot = NSDiffableDataSourceSnapshot< - ProxyConfigurationSectionIdentifier, - ProxyConfigurationItemIdentifier - >() - - snapshot.appendSections([.protocol]) - snapshot.appendItems([.protocol], toSection: .protocol) - // Reconfigure protocol cell on change. - if let previousValue, previousValue.method != newValue.method { - snapshot.reconfigureOrReloadItems([.protocol]) - } - - // Add proxy configuration section if the access method is configurable. - if newValue.method.hasProxyConfiguration { - snapshot.appendSections([.proxyConfiguration]) - } - - switch newValue.method { - case .direct, .bridges: - break - - case .shadowsocks: - snapshot.appendItems(ProxyConfigurationItemIdentifier.allShadowsocksItems, toSection: .proxyConfiguration) - // Reconfigure cipher cell on change. - if let previousValue, previousValue.shadowsocks.cipher != newValue.shadowsocks.cipher { - snapshot.reconfigureOrReloadItems([.proxyConfiguration(.shadowsocks(.cipher))]) - } - - case .socks5: - snapshot.appendItems( - ProxyConfigurationItemIdentifier.allSocksItems(authenticate: newValue.socks.authenticate), - toSection: .proxyConfiguration - ) - } - - dataSource?.apply(snapshot, animatingDifferences: animated) - } - - private func validate() { - let validationResult = Result { try subject.value.validate() } - testBarButton.isEnabled = validationResult.isSuccess && subject.value.testingStatus != .inProgress - } - - // MARK: - Misc - - private func configureTableView() { - tableView.delegate = self - tableView.backgroundColor = .secondaryColor - - view.addConstrainedSubviews([tableView]) { - tableView.pinEdgesToSuperview() - } - - addChild(contentController) - contentController.didMove(toParent: self) - } - - private func configureNavigationItem() { - navigationItem.title = NSLocalizedString( - "PROXY_CONFIGURATION_NAVIGATION_TITLE", - tableName: "APIAccess", - value: "Proxy configuration", - comment: "" - ) - navigationItem.rightBarButtonItem = testBarButton - } - - /// Update table view controller safe area to make space for the sheet at the bottom. - private func updateTableSafeAreaInsets() { - let sheetHeight = sheetPresentation.isPresenting ? sheetPresentation.sheetLayoutFrame.height : 0 - var insets = contentController.additionalSafeAreaInsets - // Prevent mutating insets if they haven't changed, in case UIKit doesn't filter duplicates. - if insets.bottom != sheetHeight { - insets.bottom = sheetHeight - contentController.additionalSafeAreaInsets = insets - } - } - - private func onTest() { - view.endEditing(true) - interactor.startProxyConfigurationTest() - } -} - -extension ProxyConfigurationViewController: AccessMethodActionSheetPresentationDelegate { - func sheetDidAdd(sheetPresentation: AccessMethodActionSheetPresentation) {} - - func sheetDidCancel(sheetPresentation: AccessMethodActionSheetPresentation) { - interactor.cancelProxyConfigurationTest() - } -} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/ProxyConfiguration/ProxyConfigurationViewControllerDelegate.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/ProxyConfiguration/ProxyConfigurationViewControllerDelegate.swift deleted file mode 100644 index 4948b7d4c351..000000000000 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/ProxyConfiguration/ProxyConfigurationViewControllerDelegate.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// ProxyConfigurationViewControllerDelegate.swift -// MullvadVPN -// -// Created by pronebird on 23/11/2023. -// Copyright © 2023 Mullvad VPN AB. All rights reserved. -// - -import Foundation - -protocol ProxyConfigurationViewControllerDelegate: AnyObject { - func controllerShouldShowProtocolPicker(_ controller: ProxyConfigurationViewController) - func controllerShouldShowShadowsocksCipherPicker(_ controller: ProxyConfigurationViewController) -} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/List/ListAccessMethodCoordinator.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/List/ListAccessMethodCoordinator.swift index 0cfa2d508932..748b3286aaee 100644 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/List/ListAccessMethodCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/List/ListAccessMethodCoordinator.swift @@ -31,7 +31,7 @@ class ListAccessMethodCoordinator: Coordinator, Presenting, SettingsChildCoordin func start(animated: Bool) { let listController = ListAccessMethodViewController( - interactor: ListAccessMethodInteractor(repo: accessMethodRepository) + interactor: ListAccessMethodInteractor(repository: accessMethodRepository) ) listController.delegate = self navigationController.pushViewController(listController, animated: animated) @@ -74,21 +74,45 @@ class ListAccessMethodCoordinator: Coordinator, Presenting, SettingsChildCoordin } private func about() { - // swiftlint:disable line_length - let aboutMarkdown = """ - **What is Lorem Ipsum?** - Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum. - """ - // swiftlint:enable line_length - - let aboutController = AboutViewController(markdown: aboutMarkdown) - let aboutNavController = UINavigationController(rootViewController: aboutController) - - aboutController.navigationItem.title = NSLocalizedString( - "ABOUT_API_ACCESS_NAV_TITLE", - value: "About API access", + let header = NSLocalizedString( + "ABOUT_API_ACCESS_HEADER", + value: "API access", comment: "" ) + let preamble = NSLocalizedString( + "ABOUT_API_ACCESS_PREAMBLE", + value: "Manage default and setup custom methods to access the Mullvad API.", + comment: "" + ) + let body = [ + NSLocalizedString( + "ABOUT_API_ACCESS_BODY_1", + value: """ + The app needs to communicate with a Mullvad API server to log you in, fetch server lists, \ + and other critical operations. + """, + comment: "" + ), + NSLocalizedString( + "ABOUT_API_ACCESS_BODY_2", + value: """ + On some networks, where various types of censorship are being used, the API servers might \ + not be directly reachable. + """, + comment: "" + ), + NSLocalizedString( + "ABOUT_API_ACCESS_BODY_3", + value: """ + This feature allows you to circumvent that censorship by adding custom ways to access the \ + API via proxies and similar methods. + """, + comment: "" + ), + ] + + let aboutController = AboutViewController(header: header, preamble: preamble, body: body) + let aboutNavController = UINavigationController(rootViewController: aboutController) aboutController.navigationItem.rightBarButtonItem = UIBarButtonItem( systemItem: .done, diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/List/ListAccessMethodHeaderView.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/List/ListAccessMethodHeaderView.swift index 039946bb7fd4..a65ceaea7924 100644 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/List/ListAccessMethodHeaderView.swift +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/List/ListAccessMethodHeaderView.swift @@ -27,6 +27,7 @@ class ListAccessMethodHeaderView: UIView, UITextViewDelegate { textView.textContainerInset = .zero textView.attributedText = makeAttributedString() textView.linkTextAttributes = defaultLinkAttributes + textView.textContainer.lineFragmentPadding = 0 textView.delegate = self directionalLayoutMargins = UIMetrics.contentHeadingLayoutMargins @@ -39,12 +40,12 @@ class ListAccessMethodHeaderView: UIView, UITextViewDelegate { } private let defaultTextAttributes: [NSAttributedString.Key: Any] = [ - .font: UIFont.systemFont(ofSize: 17), + .font: UIFont.systemFont(ofSize: 13), .foregroundColor: UIColor.ContentHeading.textColor, ] private let defaultLinkAttributes: [NSAttributedString.Key: Any] = [ - .font: UIFont.systemFont(ofSize: 17), + .font: UIFont.systemFont(ofSize: 13), .foregroundColor: UIColor.ContentHeading.linkColor, ] diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/List/ListAccessMethodInteractor.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/List/ListAccessMethodInteractor.swift index ee38216dd5a8..4cdc30afbb5a 100644 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/List/ListAccessMethodInteractor.swift +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/List/ListAccessMethodInteractor.swift @@ -7,29 +7,28 @@ // import Combine -import Foundation import MullvadSettings /// A concrete implementation of an API access list interactor. struct ListAccessMethodInteractor: ListAccessMethodInteractorProtocol { - let repo: AccessMethodRepositoryProtocol + let reporepository: AccessMethodRepositoryProtocol - init(repo: AccessMethodRepositoryProtocol) { - self.repo = repo + init(repository: AccessMethodRepositoryProtocol) { + self.reporepository = repository } var publisher: any Publisher<[ListAccessMethodItem], Never> { - repo.publisher.map { newElements in + reporepository.publisher.map { newElements in newElements.map { $0.toListItem() } } } func item(by id: UUID) -> ListAccessMethodItem? { - repo.fetch(by: id)?.toListItem() + reporepository.fetch(by: id)?.toListItem() } func fetch() -> [ListAccessMethodItem] { - repo.fetchAll().map { $0.toListItem() } + reporepository.fetchAll().map { $0.toListItem() } } } @@ -37,12 +36,18 @@ extension PersistentAccessMethod { func toListItem() -> ListAccessMethodItem { let sanitizedName = name.trimmingCharacters(in: .whitespaces) let itemName = sanitizedName.isEmpty ? kind.localizedDescription : sanitizedName - let itemDetail = sanitizedName.isEmpty ? nil : kind.localizedDescription return ListAccessMethodItem( id: id, name: itemName, - detail: itemDetail + detail: isEnabled + ? kind.localizedDescription + : NSLocalizedString( + "LIST_ACCESS_METHODS_DISABLED", + tableName: "APIAccess", + value: "Disabled", + comment: "" + ) ) } } diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/List/ListAccessMethodInteractorProtocol.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/List/ListAccessMethodInteractorProtocol.swift index 67d9553256cd..aca4e409680a 100644 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/List/ListAccessMethodInteractorProtocol.swift +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/List/ListAccessMethodInteractorProtocol.swift @@ -7,7 +7,7 @@ // import Combine -import Foundation +import MullvadSettings /// Types describing API access list interactor. protocol ListAccessMethodInteractorProtocol { @@ -17,5 +17,6 @@ protocol ListAccessMethodInteractorProtocol { /// Fetch all items. func fetch() -> [ListAccessMethodItem] + /// Publisher that produces a list of method items upon persisrtent store modifications. var publisher: any Publisher<[ListAccessMethodItem], Never> { get } } diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/List/ListAccessMethodViewController.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/List/ListAccessMethodViewController.swift index 6d8ba9b92dae..9e6e31f49729 100644 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/List/ListAccessMethodViewController.swift +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/List/ListAccessMethodViewController.swift @@ -19,14 +19,16 @@ struct ListAccessMethodItemIdentifier: Hashable { /// View controller presenting a list of API access methods. class ListAccessMethodViewController: UIViewController, UITableViewDelegate { + typealias ListAccessMethodDataSource = UITableViewDiffableDataSource< + ListAccessMethodSectionIdentifier, + ListAccessMethodItemIdentifier + > + private let headerView = ListAccessMethodHeaderView() private let interactor: ListAccessMethodInteractorProtocol private var cancellables = Set() - private var dataSource: UITableViewDiffableDataSource< - ListAccessMethodSectionIdentifier, - ListAccessMethodItemIdentifier - >? + private var dataSource: ListAccessMethodDataSource? private var fetchedItems: [ListAccessMethodItem] = [] private let contentController = UITableViewController(style: .plain) private var tableView: UITableView { @@ -58,8 +60,7 @@ class ListAccessMethodViewController: UIViewController, UITableViewDelegate { tableView.delegate = self tableView.backgroundColor = .secondaryColor tableView.separatorColor = .secondaryColor - tableView.rowHeight = UITableView.automaticDimension - tableView.estimatedRowHeight = 60 + tableView.separatorInset = .zero tableView.registerReusableViews(from: CellReuseIdentifier.self) @@ -81,6 +82,35 @@ class ListAccessMethodViewController: UIViewController, UITableViewDelegate { configureDataSource() } + func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { + let container = UIView() + + let button = AppButton(style: .tableInsetGroupedDefault) + button.setTitle( + NSLocalizedString( + "LIST_ACCESS_METHODS_ADD_BUTTON", + tableName: "APIAccess", + value: "Add", + comment: "" + ), + for: .normal + ) + button.addAction(UIAction { [weak self] _ in + self?.sendAddNew() + }, for: .touchUpInside) + + let fontSize = button.titleLabel?.font.pointSize ?? 0 + button.titleLabel?.font = UIFont.systemFont(ofSize: fontSize, weight: .regular) + + container.addConstrainedSubviews([button]) { + button.pinEdgesToSuperview(.init([.top(40), .trailing(16), .bottom(0), .leading(16)])) + } + + container.directionalLayoutMargins = UIMetrics.SettingsCell.apiAccessInsetLayoutMargins + + return container + } + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { let item = fetchedItems[indexPath.row] sendEdit(item: item) @@ -93,16 +123,10 @@ class ListAccessMethodViewController: UIViewController, UITableViewDelegate { value: "API access", comment: "" ) - navigationItem.rightBarButtonItem = UIBarButtonItem( - systemItem: .add, - primaryAction: UIAction(handler: { [weak self] _ in - self?.sendAddNew() - }) - ) } private func configureDataSource() { - dataSource = UITableViewDiffableDataSource( + dataSource = ListAccessMethodDataSource( tableView: tableView, cellProvider: { [weak self] _, indexPath, itemIdentifier in self?.dequeueCell(at: indexPath, itemIdentifier: itemIdentifier) diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/AccessMethodKind+Extension.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/AccessMethodKind+Extension.swift deleted file mode 100644 index 944e9e61380f..000000000000 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/AccessMethodKind+Extension.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// AccessMethodKind+Extension.swift -// MullvadVPN -// -// Created by Mojgan on 2023-12-19. -// Copyright © 2023 Mullvad VPN AB. All rights reserved. -// - -import Foundation -import MullvadSettings - -extension AccessMethodKind { - /// Returns localized description describing the access method. - var localizedDescription: String { - switch self { - case .direct: - NSLocalizedString("DIRECT", tableName: "APIAccess", value: "Direct", comment: "") - case .bridges: - NSLocalizedString("BRIDGES", tableName: "APIAccess", value: "Bridges", comment: "") - case .shadowsocks: - NSLocalizedString("SHADOWSOCKS", tableName: "APIAccess", value: "Shadowsocks", comment: "") - case .socks5: - NSLocalizedString("SOCKS_V5", tableName: "APIAccess", value: "Socks5", comment: "") - } - } - - /// Returns `true` if access method is configurable. - /// Methods that aren't configurable do not offer any additional configuration. - var hasProxyConfiguration: Bool { - switch self { - case .direct, .bridges: - false - case .shadowsocks, .socks5: - true - } - } -} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/AccessMethodKind.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/AccessMethodKind.swift new file mode 100644 index 000000000000..35fe1fd43ce3 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/AccessMethodKind.swift @@ -0,0 +1,79 @@ +// +// AccessMethodKind.swift +// MullvadVPN +// +// Created by pronebird on 02/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import MullvadSettings + +/// A kind of API access method. +enum AccessMethodKind: Equatable, Hashable, CaseIterable { + /// Direct communication. + case direct + + /// Communication over bridges. + case bridges + + /// Communication over shadowsocks. + case shadowsocks + + /// Communication over socks v5 proxy. + case socks5 + + /// Returns `true` if the method is permanent and cannot be deleted. + var isPermanent: Bool { + switch self { + case .direct, .bridges: + true + case .shadowsocks, .socks5: + false + } + } + + /// Returns all access method kinds that can be added by user. + static var allUserDefinedKinds: [AccessMethodKind] { + allCases.filter { !$0.isPermanent } + } + + /// Returns localized description describing the access method. + var localizedDescription: String { + switch self { + case .direct, .bridges: + "" + case .shadowsocks: + NSLocalizedString("SHADOWSOCKS", tableName: "APIAccess", value: "Shadowsocks", comment: "") + case .socks5: + NSLocalizedString("SOCKS_V5", tableName: "APIAccess", value: "Socks5", comment: "").uppercased() + } + } + + /// Returns `true` if access method is configurable. + /// Methods that aren't configurable do not offer any additional configuration. + var hasProxyConfiguration: Bool { + switch self { + case .direct, .bridges: + false + case .shadowsocks, .socks5: + true + } + } +} + +extension PersistentAccessMethod { + /// A kind of access method. + var kind: AccessMethodKind { + switch proxyConfiguration { + case .direct: + .direct + case .bridges: + .bridges + case .shadowsocks: + .shadowsocks + case .socks5: + .socks5 + } + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/AccessMethodValidationError+Helpers.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/AccessMethodValidationError+Helpers.swift deleted file mode 100644 index d1df95a84774..000000000000 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/AccessMethodValidationError+Helpers.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// AccessMethodValidationError+Helpers.swift -// MullvadVPN -// -// Created by pronebird on 29/11/2023. -// Copyright © 2023 Mullvad VPN AB. All rights reserved. -// - -import Foundation -import MullvadSettings - -extension AccessMethodValidationError { - /// Checks if any of the fields associated with the given access method have validation errors. - /// - /// - Parameter selectedMethod: the access method specified in view model - /// - Returns: `true` if any of the fields associated with the given access method have validation errors, otherwise false. - func containsProxyConfigurationErrors(selectedMethod: AccessMethodKind) -> Bool { - switch selectedMethod { - case .direct, .bridges: - false - case .shadowsocks: - fieldErrors.contains { $0.context == .shadowsocks } - case .socks5: - fieldErrors.contains { $0.context == .socks } - } - } -} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/AccessMethodValidationError.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/AccessMethodValidationError.swift index 1deef271b389..d2b41f095e90 100644 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/AccessMethodValidationError.swift +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/AccessMethodValidationError.swift @@ -15,7 +15,12 @@ struct AccessMethodValidationError: LocalizedError, Equatable { var errorDescription: String? { if fieldErrors.count > 1 { - "Multiple validation errors occurred." + NSLocalizedString( + "VALIDATION_ERRORS_MULTIPLE", + tableName: "APIAccess", + value: "Multiple validation errors occurred.", + comment: "" + ) } else { fieldErrors.first?.localizedDescription } @@ -26,7 +31,7 @@ struct AccessMethodValidationError: LocalizedError, Equatable { struct AccessMethodFieldValidationError: LocalizedError, Equatable { /// Validated field. enum Field: String, CustomStringConvertible, Equatable { - case server, port, username + case name, server, port, username, password var description: String { rawValue @@ -48,10 +53,7 @@ struct AccessMethodFieldValidationError: LocalizedError, Equatable { case emptyValue /// Failure to parse IP address. - case parseIPAddress - - /// Failure to parse port value. - case parsePort + case invalidIPAddress /// Invalid port number, i.e zero. case invalidPort @@ -67,17 +69,28 @@ struct AccessMethodFieldValidationError: LocalizedError, Equatable { let context: Context var errorDescription: String? { - var s = "The \(context) \(field) " switch kind { case .emptyValue: - s += "cannot be empty." - case .parseIPAddress: - s += "cannot be parsed as IP address." - case .parsePort: - s += "cannot be parsed as a port number." + NSLocalizedString( + "VALIDATION_ERRORS_EMPTY_FIELD", + tableName: "APIAccess", + value: "\(field) cannot be empty.", + comment: "" + ) + case .invalidIPAddress: + NSLocalizedString( + "VALIDATION_ERRORS_INVALD ADDRESS", + tableName: "APIAccess", + value: "Please enter a valid IPv4 or IPv6 address.", + comment: "" + ) case .invalidPort: - s += "contains invalid port number." + NSLocalizedString( + "VALIDATION_ERRORS_INVALID_PORT", + tableName: "APIAccess", + value: "Please enter a valid port.", + comment: "" + ) } - return s } } diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/AccessMethodViewModel+Persistent.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/AccessMethodViewModel+Persistent.swift index 8683e70a1cfb..7e25a36f3d3c 100644 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/AccessMethodViewModel+Persistent.swift +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/AccessMethodViewModel+Persistent.swift @@ -24,11 +24,27 @@ extension AccessMethodViewModel { /// - Throws: an instance of ``AccessMethodValidationError``. /// - Returns: an instance of ``PersistentAccessMethod``. func intoPersistentAccessMethod() throws -> PersistentAccessMethod { + let configuration: PersistentProxyConfiguration + + do { + configuration = try intoPersistentProxyConfiguration() + } catch var error as AccessMethodValidationError { + var fieldErrors = error.fieldErrors + + do { + _ = try validateName() + } catch let error as AccessMethodValidationError { + fieldErrors.append(contentsOf: error.fieldErrors) + } + + throw AccessMethodValidationError(fieldErrors: fieldErrors) + } + return PersistentAccessMethod( id: id, - name: name, + name: try validateName(), isEnabled: isEnabled, - proxyConfiguration: try intoPersistentProxyConfiguration() + proxyConfiguration: configuration ) } @@ -48,6 +64,16 @@ extension AccessMethodViewModel { try shadowsocks.intoPersistentProxyConfiguration() } } + + private func validateName() throws -> String { + if name.isEmpty { + // Context doesn't matter for name field. + let fieldError = AccessMethodFieldValidationError(kind: .emptyValue, field: .name, context: .shadowsocks) + throw AccessMethodValidationError(fieldErrors: [fieldError]) + } + + return name + } } extension AccessMethodViewModel.Socks { @@ -65,28 +91,42 @@ extension AccessMethodViewModel.Socks { let context: AccessMethodFieldValidationError.Context = .socks var fieldErrors: [AccessMethodFieldValidationError] = [] - switch CommonValidators.parseIPAddress(from: server, context: context) { - case let .success(serverAddress): - draftConfiguration.server = serverAddress - case let .failure(error): - fieldErrors.append(error) + if server.isEmpty { + fieldErrors.append(AccessMethodFieldValidationError(kind: .emptyValue, field: .server, context: context)) + } else { + switch CommonValidators.parseIPAddress(from: server, context: context) { + case let .success(serverAddress): + draftConfiguration.server = serverAddress + case let .failure(error): + fieldErrors.append(error) + } } - switch CommonValidators.parsePort(from: port, context: context) { - case let .success(port): - draftConfiguration.port = port - case let .failure(error): - fieldErrors.append(error) + if port.isEmpty { + fieldErrors.append(AccessMethodFieldValidationError(kind: .emptyValue, field: .port, context: context)) + } else { + switch CommonValidators.parsePort(from: port, context: context) { + case let .success(port): + draftConfiguration.port = port + case let .failure(error): + fieldErrors.append(error) + } } if authenticate { if username.isEmpty { - fieldErrors.append(AccessMethodFieldValidationError( - kind: .emptyValue, - field: .username, - context: context - )) - } else { + fieldErrors.append( + AccessMethodFieldValidationError(kind: .emptyValue, field: .username, context: context) + ) + } + + if password.isEmpty { + fieldErrors.append( + AccessMethodFieldValidationError(kind: .emptyValue, field: .password, context: context) + ) + } + + if !(username.isEmpty && password.isEmpty) { draftConfiguration.authentication = .authentication(PersistentProxyConfiguration.UserCredential( username: username, password: password @@ -118,22 +158,30 @@ extension AccessMethodViewModel.Shadowsocks { let context: AccessMethodFieldValidationError.Context = .shadowsocks var fieldErrors: [AccessMethodFieldValidationError] = [] - switch CommonValidators.parseIPAddress(from: server, context: context) { - case let .success(serverAddress): - draftConfiguration.server = serverAddress - case let .failure(error): - fieldErrors.append(error) + if server.isEmpty { + fieldErrors.append(AccessMethodFieldValidationError(kind: .emptyValue, field: .server, context: context)) + } else { + switch CommonValidators.parseIPAddress(from: server, context: context) { + case let .success(serverAddress): + draftConfiguration.server = serverAddress + case let .failure(error): + fieldErrors.append(error) + } } - switch CommonValidators.parsePort(from: port, context: context) { - case let .success(port): - draftConfiguration.port = port - case let .failure(error): - fieldErrors.append(error) + if port.isEmpty { + fieldErrors.append(AccessMethodFieldValidationError(kind: .emptyValue, field: .port, context: context)) + } else { + switch CommonValidators.parsePort(from: port, context: context) { + case let .success(port): + draftConfiguration.port = port + case let .failure(error): + fieldErrors.append(error) + } } - draftConfiguration.cipher = cipher draftConfiguration.password = password + draftConfiguration.cipher = cipher if fieldErrors.isEmpty { return .shadowsocks(draftConfiguration) @@ -153,7 +201,7 @@ private enum CommonValidators { static func parsePort(from value: String, context: AccessMethodFieldValidationError.Context) -> Result { guard let portNumber = UInt16(value) else { - return .failure(AccessMethodFieldValidationError(kind: .parsePort, field: .port, context: context)) + return .failure(AccessMethodFieldValidationError(kind: .invalidPort, field: .port, context: context)) } guard portNumber > 0 else { @@ -182,7 +230,7 @@ private enum CommonValidators { if regexMatch?.range == range, let address = AnyIPAddress(value) { return .success(address) } else { - return .failure(AccessMethodFieldValidationError(kind: .parseIPAddress, field: .server, context: context)) + return .failure(AccessMethodFieldValidationError(kind: .invalidIPAddress, field: .server, context: context)) } } } diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/AccessMethodViewModel.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/AccessMethodViewModel.swift index 4e6ba6197492..279fa966ebdc 100644 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/AccessMethodViewModel.swift +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/AccessMethodViewModel.swift @@ -58,7 +58,7 @@ struct AccessMethodViewModel: Identifiable { /// The selected access method kind. /// Determines which subview model is used when presenting proxy configuration in UI. - var method: AccessMethodKind = .socks5 + var method: AccessMethodKind = .shadowsocks /// The flag indicating whether configuration is enabled. var isEnabled = true diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/AccessViewModel+TestingStatus.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/AccessViewModel+TestingStatus.swift index d0544cf8d91a..909629e9ac67 100644 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/AccessViewModel+TestingStatus.swift +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/AccessViewModel+TestingStatus.swift @@ -9,7 +9,7 @@ import Foundation extension AccessMethodViewModel.TestingStatus { - var sheetStatus: AccessMethodActionSheetContentConfiguration.Status { + var viewStatus: MethodTestingStatusCellContentConfiguration.Status { switch self { case .initial: // The sheet is invisible in this state, the return value is not important. diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Pickers/ListItemPickerViewController.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Pickers/ListItemPickerViewController.swift index f30f75e2c5fe..0d677b55484e 100644 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Pickers/ListItemPickerViewController.swift +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Pickers/ListItemPickerViewController.swift @@ -52,7 +52,7 @@ class ListItemPickerViewController: UITa self.dataSource = dataSource self.selectedItemID = selectedItemID - super.init(style: .insetGrouped) + super.init(style: .plain) } required init?(coder: NSCoder) { @@ -63,7 +63,14 @@ class ListItemPickerViewController: UITa super.viewDidLoad() view.backgroundColor = .secondaryColor + + tableView.separatorInset = .zero + tableView.separatorColor = .secondaryColor tableView.registerReusableViews(from: CellIdentifier.self) + + // Add extra inset to mimic built-in margin of a grouped table view. Without this the + // transition between a plain and a grouped table view looks jarring. + tableView.contentInset.top = UIMetrics.SettingsCell.apiAccessPickerListContentInsetTop } override func viewIsAppearing(_ animated: Bool) { @@ -80,7 +87,7 @@ class ListItemPickerViewController: UITa override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let item = dataSource.item(at: indexPath) - var configuration = UIListContentConfiguration.mullvadCell(tableStyle: tableView.style) + var configuration = UIListContentConfiguration.mullvadCell(tableStyle: .insetGrouped) configuration.text = item.text let cell = tableView.dequeueReusableView(withIdentifier: CellIdentifier.default, for: indexPath) @@ -101,6 +108,10 @@ class ListItemPickerViewController: UITa return dataSource.itemCount } + override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + return UIMetrics.SettingsCell.apiAccessCellHeight + } + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { let selectedItem = dataSource.item(at: indexPath) selectedItemID = selectedItem.id diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Sheet/Content view/AccessMethodActionSheetConfiguration.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Sheet/Content view/AccessMethodActionSheetConfiguration.swift deleted file mode 100644 index 7704bcc3ee1f..000000000000 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Sheet/Content view/AccessMethodActionSheetConfiguration.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// AccessMethodActionSheetConfiguration.swift -// MullvadVPN -// -// Created by pronebird on 28/11/2023. -// Copyright © 2023 Mullvad VPN AB. All rights reserved. -// - -import UIKit - -/// The context in which the sheet is being used. -enum AccessMethodActionSheetContext: Equatable { - /// The variant describing the context when adding a new method. - /// - /// In this context, the sheet offers user to add access method anyway or cancel, once the API tests indicate a failure. - /// (See `contentConfiguration.status`) - case addNew - - /// The variant describing the context when the existing API method is being tested or edited as a part of proxy configuration sub-navigation. - /// - /// In this context, the sheet only offers user to cancel testing the access method. - case proxyConfiguration -} - -/// The sheet configuration. -struct AccessMethodActionSheetConfiguration: Equatable { - /// The sheet presentation context. - var context: AccessMethodActionSheetContext = .addNew - - /// The sheet content configuration. - var contentConfiguration = AccessMethodActionSheetContentConfiguration() -} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Sheet/Content view/AccessMethodActionSheetContainerView.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Sheet/Content view/AccessMethodActionSheetContainerView.swift deleted file mode 100644 index 377e204f22b2..000000000000 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Sheet/Content view/AccessMethodActionSheetContainerView.swift +++ /dev/null @@ -1,104 +0,0 @@ -// -// AccessMethodActionSheetContainerView.swift -// MullvadVPN -// -// Created by pronebird on 15/11/2023. -// Copyright © 2023 Mullvad VPN AB. All rights reserved. -// - -import UIKit - -/// The view implementing a vertical stack layout with the testing progress UI (content view) at the top and action buttons below. -class AccessMethodActionSheetContainerView: UIView { - /// Sheet delegate. - weak var delegate: AccessMethodActionSheetDelegate? - - /// Active configuration. - var configuration = AccessMethodActionSheetConfiguration() { - didSet { - contentView.configuration = configuration.contentConfiguration - updateView() - } - } - - private lazy var stackView: UIStackView = { - let stackView = UIStackView(arrangedSubviews: [contentView, addButton, cancelButton]) - stackView.axis = .vertical - stackView.spacing = UIMetrics.padding16 - return stackView - }() - - private let contentView = AccessMethodActionSheetContentView() - private let cancelButton: AppButton = { - let button = AppButton(style: .tableInsetGroupedDefault) - button.setTitle( - NSLocalizedString("SHEET_CANCEL_BUTTON", tableName: "APIAccess", value: "Cancel", comment: ""), - for: .normal - ) - return button - }() - - private let addButton: AppButton = { - let button = AppButton(style: .tableInsetGroupedDanger) - button.setTitle( - NSLocalizedString("SHEET_ADD_ANYWAY_BUTTON", tableName: "APIAccess", value: "Add anyway", comment: ""), - for: .normal - ) - button.isHidden = true - return button - }() - - override init(frame: CGRect) { - super.init(frame: frame) - setupView() - addActions() - updateView() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func setupView() { - directionalLayoutMargins = UIMetrics.contentLayoutMargins - - addConstrainedSubviews([stackView]) { - stackView.pinEdgesToSuperviewMargins() - } - } - - private func addActions() { - let cancelAction = UIAction { [weak self] _ in - self?.sendSheetDidCancel() - } - - let addAction = UIAction { [weak self] _ in - self?.sendSheetDidAdd() - } - - cancelButton.addAction(cancelAction, for: .touchUpInside) - addButton.addAction(addAction, for: .touchUpInside) - } - - private func updateView() { - let status = configuration.contentConfiguration.status - - switch configuration.context { - case .addNew: - addButton.isHidden = status != .unreachable - cancelButton.isEnabled = status != .reachable - - case .proxyConfiguration: - addButton.isHidden = true - cancelButton.isEnabled = status == .testing - } - } - - private func sendSheetDidAdd() { - delegate?.sheetDidAdd(self) - } - - private func sendSheetDidCancel() { - delegate?.sheetDidCancel(self) - } -} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Sheet/Content view/AccessMethodActionSheetDelegate.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Sheet/Content view/AccessMethodActionSheetDelegate.swift deleted file mode 100644 index 0341cd80e01f..000000000000 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Sheet/Content view/AccessMethodActionSheetDelegate.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// AccessMethodActionSheetDelegate.swift -// MullvadVPN -// -// Created by pronebird on 22/11/2023. -// Copyright © 2023 Mullvad VPN AB. All rights reserved. -// - -import Foundation - -/// Sheet container view delegate. -protocol AccessMethodActionSheetDelegate: AnyObject { - /// User tapped the cancel button. - func sheetDidCancel(_ sheet: AccessMethodActionSheetContainerView) - - /// User tapped the add button. - func sheetDidAdd(_ sheet: AccessMethodActionSheetContainerView) -} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Sheet/Presentation/AccessMethodActionSheetPresentation.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Sheet/Presentation/AccessMethodActionSheetPresentation.swift deleted file mode 100644 index 3d801a14f3ef..000000000000 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Sheet/Presentation/AccessMethodActionSheetPresentation.swift +++ /dev/null @@ -1,111 +0,0 @@ -// -// AddAccessMethodActionSheetPresentation.swift -// MullvadVPN -// -// Created by pronebird on 16/11/2023. -// Copyright © 2023 Mullvad VPN AB. All rights reserved. -// - -import UIKit - -/// Class responsible for presentation of access method sheet within the hosting view. -class AccessMethodActionSheetPresentation { - /// The view managed by the sheet presentation. - private let presentationView = AccessMethodActionSheetPresentationView(frame: CGRect( - x: 0, - y: 0, - width: 320, - height: 240 - )) - - /// Indicates whether the sheet is being presented. - private(set) var isPresenting = false - - /// Layout frame of a sheet view. - var sheetLayoutFrame: CGRect { - presentationView.sheetLayoutFrame - } - - /// Delegate. - weak var delegate: AccessMethodActionSheetPresentationDelegate? - - /// Presentation configuration. - var configuration: AccessMethodActionSheetPresentationConfiguration { - get { - presentationView.configuration - } - set { - presentationView.configuration = newValue - } - } - - init() { - presentationView.sheetDelegate = self - presentationView.alpha = 0 - } - - /// Present the sheet within the hosting view. - /// - /// - Parameters: - /// - parent: the hosting view. - /// - animated: whether to animate the transition. - func show(in parent: UIView, animated: Bool = true) { - guard !isPresenting || presentationView.superview != parent else { return } - - isPresenting = true - embed(into: parent) - - UIViewPropertyAnimator.runningPropertyAnimator( - withDuration: animated ? UIMetrics.AccessMethodActionSheetTransition.duration.timeInterval : 0, - delay: 0, - options: UIMetrics.AccessMethodActionSheetTransition.animationOptions - ) { - self.presentationView.alpha = 1 - } - } - - /// Hide the sheet from the hosting view. - /// - /// The sheet is removed from the hosting view after animation. - /// - /// - Parameter animated: whether to animate the transition. - func hide(animated: Bool = true) { - guard isPresenting else { return } - - isPresenting = false - - UIViewPropertyAnimator.runningPropertyAnimator( - withDuration: animated ? UIMetrics.AccessMethodActionSheetTransition.duration.timeInterval : 0, - delay: 0, - options: UIMetrics.AccessMethodActionSheetTransition.animationOptions - ) { - self.presentationView.alpha = 0 - } completion: { position in - guard position == .end else { return } - - self.presentationView.removeFromSuperview() - } - } - - /// Embed the container into the sheet container view into the hosting view. - /// - /// - Parameter parent: the hosting view. - private func embed(into parent: UIView) { - guard presentationView.superview != parent else { return } - - presentationView.removeFromSuperview() - parent.addConstrainedSubviews([presentationView]) { - presentationView.pinEdgesToSuperview() - } - } -} - -extension AccessMethodActionSheetPresentation: AccessMethodActionSheetDelegate { - func sheetDidAdd(_ sheet: AccessMethodActionSheetContainerView) { - delegate?.sheetDidAdd(sheetPresentation: self) - } - - func sheetDidCancel(_ sheet: AccessMethodActionSheetContainerView) { - delegate?.sheetDidCancel(sheetPresentation: self) - } -} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Sheet/Presentation/AccessMethodActionSheetPresentationConfiguration.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Sheet/Presentation/AccessMethodActionSheetPresentationConfiguration.swift deleted file mode 100644 index cc81c831d744..000000000000 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Sheet/Presentation/AccessMethodActionSheetPresentationConfiguration.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// AccessMethodActionSheetPresentationConfiguration.swift -// MullvadVPN -// -// Created by pronebird on 28/11/2023. -// Copyright © 2023 Mullvad VPN AB. All rights reserved. -// - -import Foundation - -/// Sheet presentation configuration. -struct AccessMethodActionSheetPresentationConfiguration: Equatable { - /// Whether presentation dims background. - /// When set to `false` the background is made transparent and all touches are passed through enabling interaction with the underlying view. - var dimsBackground = true - - /// Whether presentation blurs the background behind the sheet pinned at the bottom. - var blursSheetBackground = true - - /// Sheet configuration. - var sheetConfiguration = AccessMethodActionSheetConfiguration() -} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Sheet/Presentation/AccessMethodActionSheetPresentationDelegate.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Sheet/Presentation/AccessMethodActionSheetPresentationDelegate.swift deleted file mode 100644 index b32b47ee5e76..000000000000 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Sheet/Presentation/AccessMethodActionSheetPresentationDelegate.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// AccessMethodActionSheetPresentationDelegate.swift -// MullvadVPN -// -// Created by pronebird on 22/11/2023. -// Copyright © 2023 Mullvad VPN AB. All rights reserved. -// - -import Foundation - -/// Sheet presentation delegate. -protocol AccessMethodActionSheetPresentationDelegate: AnyObject { - /// User tapped the cancel button. - func sheetDidCancel(sheetPresentation: AccessMethodActionSheetPresentation) - - /// User tapped the add button. - func sheetDidAdd(sheetPresentation: AccessMethodActionSheetPresentation) -} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Sheet/Presentation/AccessMethodActionSheetPresentationView.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Sheet/Presentation/AccessMethodActionSheetPresentationView.swift deleted file mode 100644 index d561e5c52321..000000000000 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Sheet/Presentation/AccessMethodActionSheetPresentationView.swift +++ /dev/null @@ -1,124 +0,0 @@ -// -// AccessMethodActionSheetPresentationView.swift -// MullvadVPN -// -// Created by pronebird on 28/11/2023. -// Copyright © 2023 Mullvad VPN AB. All rights reserved. -// - -import UIKit - -/// The sheet presentation view implementing a layout similar to the one used by system action sheet. -class AccessMethodActionSheetPresentationView: UIView { - /// The dimming background view. - private let backgroundView: UIView = { - let backgroundView = UIView() - backgroundView.backgroundColor = .secondaryColor.withAlphaComponent(0.5) - return backgroundView - }() - - /// The blur view displayed behind the sheet. - private let sheetBlurBackgroundView: UIVisualEffectView = { - let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .dark)) - blurView.directionalLayoutMargins = .zero - blurView.contentView.directionalLayoutMargins = .zero - return blurView - }() - - /// Sheet container view that contains action buttons and access method testing progress UI. - private let sheetView = AccessMethodActionSheetContainerView(frame: CGRect(x: 0, y: 0, width: 100, height: 44)) - - /// Layout frame of a sheet content view. - var sheetLayoutFrame: CGRect { - sheetView.convert(sheetView.bounds, to: self) - } - - /// Sheet delegate. - weak var sheetDelegate: AccessMethodActionSheetDelegate? { - get { - sheetView.delegate - } - set { - sheetView.delegate = newValue - } - } - - /// Presentation configuration. - var configuration = AccessMethodActionSheetPresentationConfiguration() { - didSet { - updateSubviews(previousConfiguration: oldValue, animated: window != nil) - } - } - - override init(frame: CGRect) { - super.init(frame: frame) - - addBackgroundView() - updateSubviews(animated: false) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { - if configuration.dimsBackground { - super.point(inside: point, with: event) - } else { - // Accept touches to the content view only when background view is hidden to enable user interaction with - // the view beneath. - sheetView.frame.contains(point) - } - } - - private func addBackgroundView() { - addConstrainedSubviews([backgroundView]) { - backgroundView.pinEdgesToSuperview() - } - } - - private func updateSubviews( - previousConfiguration: AccessMethodActionSheetPresentationConfiguration? = nil, - animated: Bool - ) { - if previousConfiguration?.blursSheetBackground != configuration.blursSheetBackground { - updateSheetBackground() - } - - if previousConfiguration?.dimsBackground != configuration.dimsBackground { - updateBackgroundView(animated: animated) - } - - sheetView.configuration = configuration.sheetConfiguration - } - - private func updateSheetBackground() { - sheetView.removeFromSuperview() - sheetBlurBackgroundView.removeFromSuperview() - - // Embed the sheet view into blur view when configured to blur the sheet's background. - if configuration.blursSheetBackground { - sheetBlurBackgroundView.contentView.addConstrainedSubviews([sheetView]) { - sheetView.pinEdgesToSuperviewMargins() - } - addConstrainedSubviews([sheetBlurBackgroundView]) { - sheetBlurBackgroundView.pinEdgesToSuperview(.all().excluding(.top)) - } - } else { - addConstrainedSubviews([sheetView]) { - sheetView.pinEdgesToSuperviewMargins(.all().excluding(.top)) - } - } - } - - private func updateBackgroundView(animated: Bool) { - UIViewPropertyAnimator.runningPropertyAnimator( - withDuration: animated ? UIMetrics.AccessMethodActionSheetTransition.duration.timeInterval : 0, - delay: 0, - options: UIMetrics.AccessMethodActionSheetTransition.animationOptions, - animations: { - self.backgroundView.alpha = self.configuration.dimsBackground ? 1 : 0 - } - ) - } -} diff --git a/ios/MullvadVPN/Extensions/UIBackgroundConfiguration+Extensions.swift b/ios/MullvadVPN/Extensions/UIBackgroundConfiguration+Extensions.swift index c799388434e1..69f51d545205 100644 --- a/ios/MullvadVPN/Extensions/UIBackgroundConfiguration+Extensions.swift +++ b/ios/MullvadVPN/Extensions/UIBackgroundConfiguration+Extensions.swift @@ -11,7 +11,7 @@ import UIKit extension UIBackgroundConfiguration { /// Type of cell selection used in Mullvad UI. enum CellSelectionType { - /// Dimmed blue . + /// Dimmed blue. case dimmed /// Bright green. case green @@ -39,18 +39,14 @@ extension UIBackgroundConfiguration { /// - state: a cell state. /// - selectionType: a desired selecton type. /// - Returns: new background configuration. - func adapted(for state: UICellConfigurationState, selectionType: CellSelectionType) -> UIBackgroundConfiguration { + func adapted( + for state: UICellConfigurationState, + selectionType: CellSelectionType + ) -> UIBackgroundConfiguration { var config = self config.backgroundColor = state.mullvadCellBackgroundColor(selectionType: selectionType) return config } - - /// Apply an error outline around the cell indicating an error. - mutating func applyValidationErrorStyle() { - cornerRadius = 10 - strokeWidth = 1 - strokeColor = UIColor.Cell.validationErrorBorderColor - } } extension UICellConfigurationState { @@ -63,6 +59,8 @@ extension UICellConfigurationState { case .dimmed: if isSelected || isHighlighted { UIColor.Cell.selectedAltBackgroundColor + } else if isDisabled { + UIColor.Cell.disabledBackgroundColor } else { UIColor.Cell.backgroundColor } @@ -70,6 +68,8 @@ extension UICellConfigurationState { case .green: if isSelected || isHighlighted { UIColor.Cell.selectedBackgroundColor + } else if isDisabled { + UIColor.Cell.disabledBackgroundColor } else { UIColor.Cell.backgroundColor } diff --git a/ios/MullvadVPN/Extensions/UIListContentConfiguration+Extensions.swift b/ios/MullvadVPN/Extensions/UIListContentConfiguration+Extensions.swift index ad3da9a9ca23..3eefba09fb2d 100644 --- a/ios/MullvadVPN/Extensions/UIListContentConfiguration+Extensions.swift +++ b/ios/MullvadVPN/Extensions/UIListContentConfiguration+Extensions.swift @@ -10,51 +10,70 @@ import UIKit extension UIListContentConfiguration { /// Returns cell configured with default text attribute used in Mullvad UI. - static func mullvadCell(tableStyle: UITableView.Style) -> UIListContentConfiguration { + static func mullvadCell(tableStyle: UITableView.Style, isEnabled: Bool = true) -> UIListContentConfiguration { var configuration = cell() - configuration.textProperties.font = UIFont.systemFont(ofSize: 17) - configuration.textProperties.color = UIColor.Cell.titleTextColor - configuration.directionalLayoutMargins = tableStyle.directionalLayoutMarginsForCell + configuration.textProperties.font = .systemFont(ofSize: 17) + configuration.textProperties.color = .Cell.titleTextColor.withAlphaComponent(isEnabled ? 1 : 0.8) + configuration.axesPreservingSuperviewLayoutMargins = .vertical + + applyMargins(to: &configuration, tableStyle: tableStyle) + return configuration } /// Returns value cell configured with default text attribute used in Mullvad UI. - static func mullvadValueCell(tableStyle: UITableView.Style) -> UIListContentConfiguration { + static func mullvadValueCell(tableStyle: UITableView.Style, isEnabled: Bool = true) -> UIListContentConfiguration { var configuration = valueCell() - configuration.textProperties.font = UIFont.systemFont(ofSize: 17) - configuration.textProperties.color = UIColor.Cell.titleTextColor - configuration.secondaryTextProperties.color = UIColor.Cell.detailTextColor - configuration.secondaryTextProperties.font = UIFont.systemFont(ofSize: 17) - configuration.directionalLayoutMargins = tableStyle.directionalLayoutMarginsForCell + configuration.textProperties.font = .systemFont(ofSize: 17) + configuration.textProperties.color = .Cell.titleTextColor.withAlphaComponent(isEnabled ? 1 : 0.8) + configuration.secondaryTextProperties.color = .Cell.detailTextColor.withAlphaComponent(0.8) + configuration.secondaryTextProperties.font = .systemFont(ofSize: 17) + + applyMargins(to: &configuration, tableStyle: tableStyle) + return configuration } /// Returns grouped header configured with default text attribute used in Mullvad UI. - static func mullvadGroupedHeader() -> UIListContentConfiguration { + static func mullvadGroupedHeader(tableStyle: UITableView.Style) -> UIListContentConfiguration { var configuration = groupedHeader() - configuration.textProperties.color = UIColor.TableSection.headerTextColor - configuration.textProperties.font = UIFont.systemFont(ofSize: 17) + configuration.textProperties.color = .TableSection.headerTextColor + configuration.textProperties.font = .systemFont(ofSize: 13) + + applyMargins(to: &configuration, tableStyle: tableStyle) + return configuration } /// Returns grouped footer configured with default text attribute used in Mullvad UI. - static func mullvadGroupedFooter() -> UIListContentConfiguration { + static func mullvadGroupedFooter(tableStyle: UITableView.Style) -> UIListContentConfiguration { var configuration = groupedFooter() - configuration.textProperties.color = UIColor.TableSection.footerTextColor - configuration.textProperties.font = UIFont.systemFont(ofSize: 14) + configuration.textProperties.color = .TableSection.footerTextColor + configuration.textProperties.font = .systemFont(ofSize: 13) + + applyMargins(to: &configuration, tableStyle: tableStyle) + return configuration } + + private static func applyMargins( + to configuration: inout UIListContentConfiguration, + tableStyle: UITableView.Style + ) { + configuration.axesPreservingSuperviewLayoutMargins = .vertical + configuration.directionalLayoutMargins = tableStyle.directionalLayoutMarginsForCell + } } extension UITableView.Style { var directionalLayoutMarginsForCell: NSDirectionalEdgeInsets { switch self { case .plain, .grouped: - UIMetrics.SettingsCell.layoutMargins + UIMetrics.SettingsCell.apiAccessLayoutMargins case .insetGrouped: - UIMetrics.SettingsCell.insetLayoutMargins + UIMetrics.SettingsCell.apiAccessInsetLayoutMargins @unknown default: - UIMetrics.SettingsCell.layoutMargins + UIMetrics.SettingsCell.apiAccessLayoutMargins } } } diff --git a/ios/MullvadVPN/Extensions/UITableViewCell+Disable.swift b/ios/MullvadVPN/Extensions/UITableViewCell+Disable.swift new file mode 100644 index 000000000000..6b739e73005c --- /dev/null +++ b/ios/MullvadVPN/Extensions/UITableViewCell+Disable.swift @@ -0,0 +1,16 @@ +// +// UITableViewCell+Disable.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-01-05. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +extension UITableViewCell { + func setDisabled(_ disabled: Bool) { + isUserInteractionEnabled = !disabled + contentView.alpha = disabled ? 0.8 : 1 + } +} diff --git a/ios/MullvadVPN/UI appearance/UIColor+Palette.swift b/ios/MullvadVPN/UI appearance/UIColor+Palette.swift index 21a08cf894e4..0f111024b36c 100644 --- a/ios/MullvadVPN/UI appearance/UIColor+Palette.swift +++ b/ios/MullvadVPN/UI appearance/UIColor+Palette.swift @@ -72,8 +72,8 @@ extension UIColor { // Navigation bars enum NavigationBar { - static let backButtonIndicatorColor = UIColor(white: 1.0, alpha: 0.4) - static let backButtonTitleColor = UIColor(white: 1.0, alpha: 0.6) + static let backButtonIndicatorColor = UIColor(white: 1.0, alpha: 0.8) + static let backButtonTitleColor = UIColor.white static let titleColor = UIColor.white static let promptColor = UIColor.white } diff --git a/ios/MullvadVPN/UI appearance/UIMetrics.swift b/ios/MullvadVPN/UI appearance/UIMetrics.swift index 33c2e69e926a..a04e1e1ae041 100644 --- a/ios/MullvadVPN/UI appearance/UIMetrics.swift +++ b/ios/MullvadVPN/UI appearance/UIMetrics.swift @@ -56,7 +56,7 @@ enum UIMetrics { } enum SettingsRedeemVoucher { - static let cornerRadius = 8.0 + static let cornerRadius: CGFloat = 8 static let preferredContentSize = CGSize(width: 280, height: 260) static let contentLayoutMargins = NSDirectionalEdgeInsets(top: 16, leading: 16, bottom: 16, trailing: 16) static let successfulRedeemMargins = NSDirectionalEdgeInsets(top: 16, leading: 8, bottom: 16, trailing: 8) @@ -67,7 +67,7 @@ enum UIMetrics { } enum Button { - static let barButtonSize: CGFloat = 44.0 + static let barButtonSize: CGFloat = 44 } enum SettingsCell { @@ -80,6 +80,12 @@ enum UIMetrics { /// Cell layout margins used in table views that use inset style. static let insetLayoutMargins = NSDirectionalEdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24) + + static let apiAccessLayoutMargins = NSDirectionalEdgeInsets(top: 20, leading: 16, bottom: 20, trailing: 16) + static let apiAccessInsetLayoutMargins = NSDirectionalEdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16) + static let apiAccessCellHeight: CGFloat = 44 + static let apiAccessSwitchCellTrailingMargin: CGFloat = apiAccessInsetLayoutMargins.trailing - 4 + static let apiAccessPickerListContentInsetTop: CGFloat = 16 } enum InAppBannerNotification { diff --git a/ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionContentView.swift b/ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionContentView.swift index ea9eef36b019..3dc3572e44f7 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/MullvadVPN/View controllers/Alert/AlertViewController.swift b/ios/MullvadVPN/View controllers/Alert/AlertViewController.swift index 3d0e0a29ed77..2f1796b0639c 100644 --- a/ios/MullvadVPN/View controllers/Alert/AlertViewController.swift +++ b/ios/MullvadVPN/View controllers/Alert/AlertViewController.swift @@ -24,6 +24,7 @@ enum AlertActionStyle { enum AlertIcon { case alert + case warning case info case spinner @@ -31,6 +32,8 @@ enum AlertIcon { switch self { case .alert: return UIImage(named: "IconAlert")?.withTintColor(.dangerColor) + case .warning: + return UIImage(named: "IconAlert")?.withTintColor(.white) case .info: return UIImage(named: "IconInfo")?.withTintColor(.white) default: diff --git a/ios/MullvadVPN/Views/AppButton.swift b/ios/MullvadVPN/Views/AppButton.swift index 245a0f5f9a39..c2581d5c7180 100644 --- a/ios/MullvadVPN/Views/AppButton.swift +++ b/ios/MullvadVPN/Views/AppButton.swift @@ -71,11 +71,11 @@ class AppButton: CustomButton { case .translucentDangerSplitRight: UIImage(resource: .translucentDangerSplitRightButton).imageFlippedForRightToLeftLayoutDirection() case .tableInsetGroupedDefault: - DynamicAssets.shared.tableInsetGroupedDefaultBackground + UIImage(resource: .defaultButton) case .tableInsetGroupedSuccess: - DynamicAssets.shared.tableInsetGroupedSuccessBackground + UIImage(resource: .successButton) case .tableInsetGroupedDanger: - DynamicAssets.shared.tableInsetGroupedDangerBackground + UIImage(resource: .dangerButton) } } } @@ -169,37 +169,3 @@ class AppButton: CustomButton { } } } - -private extension AppButton { - class DynamicAssets { - static let shared = DynamicAssets() - - private init() {} - - /// Default cell corner radius in inset grouped table view - private let tableViewCellCornerRadius: CGFloat = 10 - - lazy var tableInsetGroupedDefaultBackground: UIImage = { - roundedRectImage(fillColor: .primaryColor) - }() - - lazy var tableInsetGroupedSuccessBackground: UIImage = { - roundedRectImage(fillColor: .successColor) - }() - - lazy var tableInsetGroupedDangerBackground: UIImage = { - roundedRectImage(fillColor: .dangerColor) - }() - - private func roundedRectImage(fillColor: UIColor) -> UIImage { - let cornerRadius = tableViewCellCornerRadius - let bounds = CGRect(x: 0, y: 0, width: 44, height: 44) - let image = UIGraphicsImageRenderer(bounds: bounds).image { _ in - fillColor.setFill() - UIBezierPath(roundedRect: bounds, cornerRadius: cornerRadius).fill() - } - let caps = UIEdgeInsets(top: cornerRadius, left: cornerRadius, bottom: cornerRadius, right: cornerRadius) - return image.resizableImage(withCapInsets: caps) - } - } -} diff --git a/ios/MullvadVPNTests/APIAccessMethodsTests.swift b/ios/MullvadVPNTests/APIAccessMethodsTests.swift index f30886f5e35a..b7f3e18b78a4 100644 --- a/ios/MullvadVPNTests/APIAccessMethodsTests.swift +++ b/ios/MullvadVPNTests/APIAccessMethodsTests.swift @@ -6,6 +6,7 @@ // Copyright © 2023 Mullvad VPN AB. All rights reserved. // +import Combine @testable import MullvadSettings import XCTest @@ -48,7 +49,7 @@ final class APIAccessMethodsTests: XCTestCase { let uuid = UUID() let methodToStore = socks5AccessMethod(with: uuid) - repository.add(methodToStore) + repository.save(methodToStore) let storedMethod = repository.fetch(by: uuid) @@ -60,7 +61,7 @@ final class APIAccessMethodsTests: XCTestCase { let uuid = UUID() let methodToStore = shadowsocksAccessMethod(with: uuid) - repository.add(methodToStore) + repository.save(methodToStore) let storedMethod = repository.fetch(by: uuid) @@ -72,8 +73,8 @@ final class APIAccessMethodsTests: XCTestCase { let methodToStore = socks5AccessMethod(with: UUID()) - repository.add(methodToStore) - repository.add(methodToStore) + repository.save(methodToStore) + repository.save(methodToStore) let storedMethods = repository.fetchAll() @@ -86,12 +87,12 @@ final class APIAccessMethodsTests: XCTestCase { let uuid = UUID() var methodToStore = socks5AccessMethod(with: uuid) - repository.add(methodToStore) + repository.save(methodToStore) let newName = "Renamed method" methodToStore.name = newName - repository.update(methodToStore) + repository.save(methodToStore) let storedMethod = repository.fetch(by: uuid) @@ -103,7 +104,7 @@ final class APIAccessMethodsTests: XCTestCase { let uuid = UUID() let methodToStore = socks5AccessMethod(with: uuid) - repository.add(methodToStore) + repository.save(methodToStore) repository.delete(id: uuid) let storedMethod = repository.fetch(by: uuid) diff --git a/ios/PacketTunnel/PacketTunnelProvider/PacketTunnelProvider.swift b/ios/PacketTunnel/PacketTunnelProvider/PacketTunnelProvider.swift index 735ee455b9b9..fc0dcce43f93 100644 --- a/ios/PacketTunnel/PacketTunnelProvider/PacketTunnelProvider.swift +++ b/ios/PacketTunnel/PacketTunnelProvider/PacketTunnelProvider.swift @@ -41,9 +41,8 @@ class PacketTunnelProvider: NEPacketTunnelProvider { let shadowsocksCache = ShadowsocksConfigurationCache(cacheDirectory: containerURL) // This init cannot fail as long as the security group identifier is valid - let sharedUserDefaults = UserDefaults(suiteName: ApplicationConfiguration.securityGroupIdentifier)! let transportStrategy = TransportStrategy( - sharedUserDefaults, + UserDefaults(suiteName: ApplicationConfiguration.securityGroupIdentifier)!, datasource: AccessMethodRepository(), shadowsocksLoader: ShadowsocksLoader( shadowsocksCache: shadowsocksCache, @@ -77,7 +76,6 @@ class PacketTunnelProvider: NEPacketTunnelProvider { let devicesProxy = proxyFactory.createDevicesProxy() deviceChecker = DeviceChecker(accountsProxy: accountsProxy, devicesProxy: devicesProxy) - let obfuscator = ProtocolObfuscator() actor = PacketTunnelActor( timings: PacketTunnelActorTimings(), @@ -87,7 +85,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { blockedStateErrorMapper: BlockedStateErrorMapper(), relaySelector: RelaySelectorWrapper(relayCache: relayCache), settingsReader: SettingsReader(), - protocolObfuscator: obfuscator + protocolObfuscator: ProtocolObfuscator() ) let urlRequestProxy = URLRequestProxy(dispatchQueue: internalQueue, transportProvider: transportProvider)