diff options
| author | Bug Magnet <marco.nikic@mullvad.net> | 2025-06-10 16:14:52 +0200 |
|---|---|---|
| committer | Bug Magnet <marco.nikic@mullvad.net> | 2025-06-10 16:14:52 +0200 |
| commit | ddfcd72094c3bc3fedce22ce5003b98a8bff2504 (patch) | |
| tree | b69e2a885d7f110386f369a81456a4385d6950f2 | |
| parent | 6d6cf338b2babd95590ee0f507f7fd9f7e048859 (diff) | |
| parent | 5a86d15003f012f4ef2b98d66b9930fe342c9f10 (diff) | |
| download | mullvadvpn-ddfcd72094c3bc3fedce22ce5003b98a8bff2504.tar.xz mullvadvpn-ddfcd72094c3bc3fedce22ce5003b98a8bff2504.zip | |
Merge branch 'implement-quick-access-design-ios-1175'
21 files changed, 539 insertions, 118 deletions
diff --git a/ios/CHANGELOG.md b/ios/CHANGELOG.md index 1dfd86c9db..396012005b 100644 --- a/ios/CHANGELOG.md +++ b/ios/CHANGELOG.md @@ -23,17 +23,29 @@ Line wrap the file at 100 chars. Th ## Unreleased ### Added -- Make account number copyable on welcome screen. +- Make feature indicators clickable shortcuts to their corresponding settings. ### Changed -- Improve the filter view to display the number of available servers based on selected criteria. +- Improve location view to filter out servers not compatible with custom obfuscation port. - Make the app feel more responsive when reconnecting. - Replace Classic McEliece with HQC as one of the post-quantum safe key exchange mechanisms used for the quantum-resistant tunnels. The main benefits here are that HQC uses a lot less CPU to compute the keypair, and the public key sent to the server is drastically smaller. -## 2025.2 - 2025-02-08 +## [2025.4 - 2025-05-20] +### Added +- Make account number copyable on welcome screen. +- Add animations for connection view. + +### Changed +- Improve the filter view to display the number of available servers based on selected criteria. + +## [2025.3 - 2025-03-06] +### Fixed +- Fix DAITA for multihop. + +## [2025.2 - 2025-02-08] ### Added - Add different themes for app icons @@ -46,10 +58,6 @@ Line wrap the file at 100 chars. Th ### Removed - Remove Google's resolvers from encrypted DNS proxy. -## [2025.3 - 2025-03-06] -### Fixed -- Fix DAITA for multihop. - ## [2025.1 - 2025-01-14] ### Added - Update to DAITA v2 - now machines are provided by relays dynamically instead diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index 2251f071e2..eb12f8006a 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -1112,13 +1112,16 @@ F910A43A2D4A283D002FF3BB /* InAppPurchaseViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F910A4392D4A2839002FF3BB /* InAppPurchaseViewController.swift */; }; F910A8572D523812002FF3BB /* TunnelSettingsV7.swift in Sources */ = {isa = PBXBuildFile; fileRef = F910A8562D523812002FF3BB /* TunnelSettingsV7.swift */; }; F91B94A72DC9EB5E00132C28 /* MullvadInfoHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F91B94A62DC9EB5E00132C28 /* MullvadInfoHeaderView.swift */; }; + F924C4532D70692E001F4660 /* MullvadApiTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F924C4522D706929001F4660 /* MullvadApiTests.swift */; }; + F924C5A42DA65F28001F4660 /* Storekit2.swift in Sources */ = {isa = PBXBuildFile; fileRef = F924C5A32DA65F28001F4660 /* Storekit2.swift */; }; + F924C65F2DAE4554001F4660 /* ServerRelayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F924C65E2DAE4554001F4660 /* ServerRelayTests.swift */; }; F9276C622DBA2103006FE43D /* Font+Mullvad.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9276C612DBA20FC006FE43D /* Font+Mullvad.swift */; }; F9394EEC2DBF56B6009595EA /* Color+Mullvad.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9394EEB2DBF56AA009595EA /* Color+Mullvad.swift */; }; F9394EF02DC0B58D009595EA /* MullvadListNavigationItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9394EEF2DC0B58D009595EA /* MullvadListNavigationItemView.swift */; }; F9394EF32DC21D8C009595EA /* MullvadList.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9394EF22DC21D8C009595EA /* MullvadList.swift */; }; - F924C4532D70692E001F4660 /* MullvadApiTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F924C4522D706929001F4660 /* MullvadApiTests.swift */; }; - F924C5A42DA65F28001F4660 /* Storekit2.swift in Sources */ = {isa = PBXBuildFile; fileRef = F924C5A32DA65F28001F4660 /* Storekit2.swift */; }; - F924C65F2DAE4554001F4660 /* ServerRelayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F924C65E2DAE4554001F4660 /* ServerRelayTests.swift */; }; + F97C38C82DE48AAE006DCB08 /* Color+Mullvad.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9394EEB2DBF56AA009595EA /* Color+Mullvad.swift */; }; + F97C38CA2DE49869006DCB08 /* MultihopSettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F97C38C92DE49869006DCB08 /* MultihopSettingsCoordinator.swift */; }; + F97C38D92DE5930F006DCB08 /* CustomDNSCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F97C38D82DE59307006DCB08 /* CustomDNSCoordinator.swift */; }; F998EFF82D359C4600D88D01 /* SKProduct+Formatting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FD5BEF24238EB300112C88 /* SKProduct+Formatting.swift */; }; F998EFFA2D3656BA00D88D01 /* SKProduct+Sorting.swift in Sources */ = {isa = PBXBuildFile; fileRef = F998EFF92D3656B100D88D01 /* SKProduct+Sorting.swift */; }; F9E3BCF72DD35B78009986C3 /* ListAccessViewModelBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9E3BCF62DD35B78009986C3 /* ListAccessViewModelBridge.swift */; }; @@ -2541,6 +2544,8 @@ F9394EEB2DBF56AA009595EA /* Color+Mullvad.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Mullvad.swift"; sourceTree = "<group>"; }; F9394EEF2DC0B58D009595EA /* MullvadListNavigationItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MullvadListNavigationItemView.swift; sourceTree = "<group>"; }; F9394EF22DC21D8C009595EA /* MullvadList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MullvadList.swift; sourceTree = "<group>"; }; + F97C38C92DE49869006DCB08 /* MultihopSettingsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultihopSettingsCoordinator.swift; sourceTree = "<group>"; }; + F97C38D82DE59307006DCB08 /* CustomDNSCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDNSCoordinator.swift; sourceTree = "<group>"; }; F998EFF92D3656B100D88D01 /* SKProduct+Sorting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SKProduct+Sorting.swift"; sourceTree = "<group>"; }; F9E3BCF62DD35B78009986C3 /* ListAccessViewModelBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListAccessViewModelBridge.swift; sourceTree = "<group>"; }; /* End PBXFileReference section */ @@ -3256,6 +3261,7 @@ 583FE01A29C19777006E85F9 /* VPNSettings */ = { isa = PBXGroup; children = ( + F97C38D82DE59307006DCB08 /* CustomDNSCoordinator.swift */, 7A6F2FAA2AFD3097006D0856 /* CustomDNSCellFactory.swift */, 7A6F2FA82AFD0842006D0856 /* CustomDNSDataSource.swift */, 7A6F2FAC2AFD3DA7006D0856 /* CustomDNSViewController.swift */, @@ -4347,6 +4353,7 @@ 7A8A18F72CE34E8F000BCB5B /* Multihop */ = { isa = PBXGroup; children = ( + F97C38C92DE49869006DCB08 /* MultihopSettingsCoordinator.swift */, 7A8A19152CEF2696000BCB5B /* MultihopTunnelSettingsViewModel.swift */, 7A8A18F82CE34E9F000BCB5B /* SettingsMultihopView.swift */, ); @@ -5941,7 +5948,7 @@ 7A5869C32B5820CE00640D27 /* IPOverrideRepositoryTests.swift in Sources */, A9A5FA392ACB05910083449F /* UIColor+Palette.swift in Sources */, 7A5468AD2C6B5E4B00590086 /* LocationRelays.swift in Sources */, - F9394EEC2DBF56B6009595EA /* Color+Mullvad.swift in Sources */, + F97C38C82DE48AAE006DCB08 /* Color+Mullvad.swift in Sources */, A9A5FA3A2ACB05910083449F /* UIEdgeInsets+Extensions.swift in Sources */, A9A5FA3B2ACB05910083449F /* UIMetrics.swift in Sources */, 58B07C182AEFDD6C00A09625 /* StoreTransactionLog.swift in Sources */, @@ -6483,6 +6490,7 @@ 7A6389E22B7E3BD6008E77E1 /* CustomListInteractor.swift in Sources */, F07CFF2029F2720E008C0343 /* NewDeviceNotificationProvider.swift in Sources */, 7A6389E12B7E3BD6008E77E1 /* CustomListSectionIdentifier.swift in Sources */, + F97C38CA2DE49869006DCB08 /* MultihopSettingsCoordinator.swift in Sources */, 58CEB2F32AFD0BA100E6E088 /* TextCellContentView.swift in Sources */, 7A6389E72B7E42BE008E77E1 /* CustomListViewController.swift in Sources */, 586C0D7C2B03BDD100E7CDD7 /* AccessMethodViewModel.swift in Sources */, @@ -6613,6 +6621,7 @@ 587425C12299833500CA2045 /* RootContainerViewController.swift in Sources */, 7AB3BEB52BD7A6CB00E34384 /* LocationViewControllerWrapper.swift in Sources */, F09D04BD2AEBB7C5003D4F89 /* OutgoingConnectionService.swift in Sources */, + F97C38D92DE5930F006DCB08 /* CustomDNSCoordinator.swift in Sources */, 58FF9FF42B07C61B00E4C97D /* AccessMethodValidationError.swift in Sources */, 5896AE84246D5889005B36CB /* CustomDateComponentsFormatting.swift in Sources */, 5871167F2910035700D41AAC /* VPNSettingsInteractor.swift in Sources */, diff --git a/ios/MullvadVPN/Classes/AppRoutes.swift b/ios/MullvadVPN/Classes/AppRoutes.swift index 5fb75bdf5d..697c59f79a 100644 --- a/ios/MullvadVPN/Classes/AppRoutes.swift +++ b/ios/MullvadVPN/Classes/AppRoutes.swift @@ -83,6 +83,26 @@ enum AppRoute: AppRouteProtocol { case settings(SettingsNavigationRoute?) /** + Settings route. Contains sub-route to display. + */ + case vpnSettings(VPNSettingsSection?) + + /** + Multihop standalone route (not subsetting). + */ + case multihop + + /** + DNS settings standalone route (not subsetting). + */ + case dnsSettings + + /** + Ip overrides standalone route (not subsetting). + */ + case ipOverrides + + /** DAITA standalone route (not subsetting). */ case daita @@ -133,7 +153,7 @@ enum AppRoute: AppRouteProtocol { return .selectLocation case .account: return .account - case .settings, .daita, .changelog: + case .settings, .daita, .changelog, .vpnSettings, .multihop, .dnsSettings, .ipOverrides: return .settings case let .alert(id): return .alert(id) diff --git a/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift b/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift index 87a2cbf24e..b429c9053e 100644 --- a/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift @@ -151,6 +151,18 @@ final class ApplicationCoordinator: Coordinator, Presenting, @preconcurrency Roo case .alert: presentAlert(animated: animated, context: context, completion: completion) + case let .vpnSettings(section): + presentVPNSettings( + section: section, + animated: animated, + completion: completion + ) + case .multihop: + presentMultihop(animated: animated, completion: completion) + case .dnsSettings: + presentDNSSettings(animated: animated, completion: completion) + case .ipOverrides: + presentIPOverride(animated: animated, completion: completion) } } @@ -502,6 +514,10 @@ final class ApplicationCoordinator: Coordinator, Presenting, @preconcurrency Roo self?.router.present(.selectLocation, animated: true) } + tunnelCoordinator.showFeatureSetting = { [weak self] route in + self?.router.present(route, animated: true) + } + return tunnelCoordinator } @@ -605,6 +621,35 @@ final class ApplicationCoordinator: Coordinator, Presenting, @preconcurrency Roo } } + func presentVPNSettings( + section: VPNSettingsSection?, + animated: Bool, + completion: @escaping @Sendable (Coordinator) -> Void + ) { + let interactorFactory = SettingsInteractorFactory( + tunnelManager: tunnelManager, + apiProxy: apiProxy, + relayCacheTracker: relayCacheTracker, + ipOverrideRepository: ipOverrideRepository + ) + let coordinator = VPNSettingsCoordinator( + navigationController: CustomNavigationController(), + interactorFactory: interactorFactory, + ipOverrideRepository: ipOverrideRepository, + route: .vpnSettings(section) + ) + + coordinator.didFinish = { [weak self] _ in + self?.router.dismiss(.vpnSettings(section), animated: true) + } + + coordinator.start(animated: animated) + + presentChild(coordinator, animated: animated) { + completion(coordinator) + } + } + private func presentDAITA(animated: Bool, completion: @escaping @Sendable (Coordinator) -> Void) { let viewModel = DAITATunnelSettingsViewModel(tunnelManager: tunnelManager) let coordinator = DAITASettingsCoordinator( @@ -624,6 +669,67 @@ final class ApplicationCoordinator: Coordinator, Presenting, @preconcurrency Roo } } + private func presentMultihop(animated: Bool, completion: @escaping @Sendable (Coordinator) -> Void) { + let viewModel = MultihopTunnelSettingsViewModel( + tunnelManager: tunnelManager + ) + let coordinator = MultihopSettingsCoordinator( + navigationController: CustomNavigationController(), + route: .multihop, + viewModel: viewModel + ) + + coordinator.didFinish = { [weak self] _ in + self?.router.dismiss(.multihop, animated: true) + } + + coordinator.start(animated: animated) + + presentChild(coordinator, animated: animated) { + completion(coordinator) + } + } + + private func presentIPOverride(animated: Bool, completion: @escaping @Sendable (Coordinator) -> Void) { + let coordinator = IPOverrideCoordinator( + navigationController: CustomNavigationController(), + repository: ipOverrideRepository, + tunnelManager: tunnelManager, + route: .ipOverrides + ) + + coordinator.didFinish = { [weak self] _ in + self?.router.dismiss(.ipOverrides, animated: true) + } + + coordinator.start(animated: animated) + + presentChild(coordinator, animated: animated) { + completion(coordinator) + } + } + + private func presentDNSSettings(animated: Bool, completion: @escaping @Sendable (Coordinator) -> Void) { + let coordinator = CustomDNSCoordinator( + navigationController: CustomNavigationController(), + interactor: VPNSettingsInteractor( + tunnelManager: tunnelManager, + relayCacheTracker: relayCacheTracker + ), + route: .dnsSettings + ) + + coordinator.didFinish = { [weak self] _ in + self?.router.dismiss(.dnsSettings, animated: true) + } + + coordinator.start(animated: animated) + + presentChild(coordinator, animated: animated) { + completion(coordinator) + } + } + private func addTunnelObserver() { let tunnelObserver = TunnelBlockObserver( diff --git a/ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideCoordinator.swift b/ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideCoordinator.swift index 6e43027a95..953189d5a2 100644 --- a/ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideCoordinator.swift @@ -11,21 +11,29 @@ import MullvadTypes import Routing import UIKit -class IPOverrideCoordinator: Coordinator, Presenting, SettingsChildCoordinator { +class IPOverrideCoordinator: Coordinator, Presentable, Presenting, SettingsChildCoordinator { private let navigationController: UINavigationController private let interactor: IPOverrideInteractor - + private let route: AppRoute? var presentationContext: UIViewController { navigationController } + var presentedViewController: UIViewController { + navigationController + } + + var didFinish: ((IPOverrideCoordinator) -> Void)? + init( navigationController: UINavigationController, repository: IPOverrideRepositoryProtocol, - tunnelManager: TunnelManager + tunnelManager: TunnelManager, + route: AppRoute? ) { self.navigationController = navigationController interactor = IPOverrideInteractor(repository: repository, tunnelManager: tunnelManager) + self.route = route } func start(animated: Bool) { @@ -36,6 +44,17 @@ class IPOverrideCoordinator: Coordinator, Presenting, SettingsChildCoordinator { controller.delegate = self + if route == .ipOverrides { + let doneButton = UIBarButtonItem( + systemItem: .done, + primaryAction: UIAction(handler: { [weak self] _ in + guard let self else { return } + didFinish?(self) + }) + ) + controller.navigationItem.rightBarButtonItem = doneButton + } + navigationController.pushViewController(controller, animated: animated) } } diff --git a/ios/MullvadVPN/Coordinators/Settings/Multihop/MultihopSettingsCoordinator.swift b/ios/MullvadVPN/Coordinators/Settings/Multihop/MultihopSettingsCoordinator.swift new file mode 100644 index 0000000000..b54bab2368 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/Multihop/MultihopSettingsCoordinator.swift @@ -0,0 +1,69 @@ +// +// DAITASettingsCoordinator.swift +// MullvadVPN +// +// Created by Jon Petersson on 2025-01-20. +// Copyright © 2025 Mullvad VPN AB. All rights reserved. +// + +import Routing +import SwiftUI + +class MultihopSettingsCoordinator: Coordinator, SettingsChildCoordinator, Presentable, Presenting { + private let navigationController: UINavigationController + private let viewModel: MultihopTunnelSettingsViewModel + private var alertPresenter: AlertPresenter? + private let route: AppRoute + + var presentedViewController: UIViewController { + navigationController + } + + var didFinish: ((MultihopSettingsCoordinator) -> Void)? + + init( + navigationController: UINavigationController, + route: AppRoute, + viewModel: MultihopTunnelSettingsViewModel + ) { + self.navigationController = navigationController + self.route = route + self.viewModel = viewModel + + super.init() + + alertPresenter = AlertPresenter(context: self) + } + + func start(animated: Bool) { + let view = SettingsMultihopView(tunnelViewModel: self.viewModel) + + let host = UIHostingController(rootView: view) + host.title = NSLocalizedString( + "NAVIGATION_TITLE_MULTIHOP", + tableName: "Settings", + value: "Multihop", + comment: "" + ) + host.view.setAccessibilityIdentifier(.multihopView) + customiseNavigation(on: host) + + navigationController.pushViewController(host, animated: animated) + } + + private func customiseNavigation(on viewController: UIViewController) { + if route == .multihop { + navigationController.navigationItem.largeTitleDisplayMode = .always + navigationController.navigationBar.prefersLargeTitles = true + + let doneButton = UIBarButtonItem( + systemItem: .done, + primaryAction: UIAction(handler: { [weak self] _ in + guard let self else { return } + didFinish?(self) + }) + ) + viewController.navigationItem.rightBarButtonItem = doneButton + } + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/SettingsViewControllerFactory.swift b/ios/MullvadVPN/Coordinators/Settings/SettingsViewControllerFactory.swift index 33119bf4b3..ea2ef51e32 100644 --- a/ios/MullvadVPN/Coordinators/Settings/SettingsViewControllerFactory.swift +++ b/ios/MullvadVPN/Coordinators/Settings/SettingsViewControllerFactory.swift @@ -76,7 +76,8 @@ struct SettingsViewControllerFactory { return .childCoordinator(VPNSettingsCoordinator( navigationController: navigationController, interactorFactory: interactorFactory, - ipOverrideRepository: ipOverrideRepository + ipOverrideRepository: ipOverrideRepository, + route: .settings(.vpnSettings) )) } diff --git a/ios/MullvadVPN/Coordinators/TunnelCoordinator.swift b/ios/MullvadVPN/Coordinators/TunnelCoordinator.swift index 0ff34e9062..e7a562f9a6 100644 --- a/ios/MullvadVPN/Coordinators/TunnelCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/TunnelCoordinator.swift @@ -24,6 +24,7 @@ class TunnelCoordinator: Coordinator, Presenting { } var showSelectLocationPicker: (() -> Void)? + var showFeatureSetting: ((AppRoute) -> Void)? init( tunnelManager: TunnelManager, @@ -49,6 +50,23 @@ class TunnelCoordinator: Coordinator, Presenting { controller.shouldShowCancelTunnelAlert = { [weak self] in self?.showCancelTunnelAlert() } + + controller.shouldShowSettingsForFeature = { [weak self] feature in + switch feature { + case .daita: + self?.showFeatureSetting?(.daita) + case .multihop: + self?.showFeatureSetting?(.multihop) + case .quantumResistance: + self?.showFeatureSetting?(.vpnSettings(.quantumResistance)) + case .obfuscation: + self?.showFeatureSetting?(.vpnSettings(.obfuscation)) + case .dns: + self?.showFeatureSetting?(.dnsSettings) + case .ipOverrides: + self?.showFeatureSetting?(.ipOverrides) + } + } } func start() { diff --git a/ios/MullvadVPN/Coordinators/VPNSettingsCoordinator.swift b/ios/MullvadVPN/Coordinators/VPNSettingsCoordinator.swift index dc099d6166..805cdd2a5a 100644 --- a/ios/MullvadVPN/Coordinators/VPNSettingsCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/VPNSettingsCoordinator.swift @@ -11,35 +11,66 @@ import MullvadTypes import Routing import UIKit -class VPNSettingsCoordinator: Coordinator, Presenting, SettingsChildCoordinator { +enum VPNSettingsSection: Equatable { + case quantumResistance + case obfuscation +} + +class VPNSettingsCoordinator: Coordinator, Presenting, Presentable, SettingsChildCoordinator { private let navigationController: UINavigationController private let interactorFactory: SettingsInteractorFactory private let ipOverrideRepository: IPOverrideRepositoryProtocol + private let route: AppRoute var presentationContext: UIViewController { navigationController } + var presentedViewController: UIViewController { + navigationController + } + + var didFinish: ((VPNSettingsCoordinator) -> Void)? + init( navigationController: UINavigationController, interactorFactory: SettingsInteractorFactory, - ipOverrideRepository: IPOverrideRepositoryProtocol + ipOverrideRepository: IPOverrideRepositoryProtocol, + route: AppRoute ) { self.navigationController = navigationController self.interactorFactory = interactorFactory self.ipOverrideRepository = ipOverrideRepository + self.route = route } func start(animated: Bool) { + let section: VPNSettingsSection? = if case let .vpnSettings(route) = route { route } else { + nil + } let controller = VPNSettingsViewController( interactor: interactorFactory.makeVPNSettingsInteractor(), - alertPresenter: AlertPresenter(context: self) + alertPresenter: AlertPresenter(context: self), + section: section ) controller.delegate = self - + customiseNavigation(on: controller) navigationController.pushViewController(controller, animated: animated) } + + private func customiseNavigation(on viewController: UIViewController) { + if case .vpnSettings = route { + let doneButton = UIBarButtonItem( + systemItem: .done, + primaryAction: UIAction(handler: { [weak self] _ in + guard let self else { return } + didFinish?(self) + }) + ) + viewController.navigationItem.rightBarButtonItem = doneButton + } + } } extension VPNSettingsCoordinator: @preconcurrency VPNSettingsViewControllerDelegate { @@ -47,7 +78,8 @@ extension VPNSettingsCoordinator: @preconcurrency VPNSettingsViewControllerDeleg let coordinator = IPOverrideCoordinator( navigationController: navigationController, repository: ipOverrideRepository, - tunnelManager: interactorFactory.tunnelManager + tunnelManager: interactorFactory.tunnelManager, + route: nil ) addChild(coordinator) diff --git a/ios/MullvadVPN/View controllers/Settings/SettingsHeaderView.swift b/ios/MullvadVPN/View controllers/Settings/SettingsHeaderView.swift index 1db4841b8a..fe2a1e0a55 100644 --- a/ios/MullvadVPN/View controllers/Settings/SettingsHeaderView.swift +++ b/ios/MullvadVPN/View controllers/Settings/SettingsHeaderView.swift @@ -49,7 +49,12 @@ class SettingsHeaderView: UITableViewHeaderFooterView { } } - var didCollapseHandler: CollapseHandler? + var didCollapseHandler: CollapseHandler? { + didSet { + collapseButton.isHidden = didCollapseHandler == nil + } + } + var infoButtonHandler: InfoButtonHandler? { didSet { infoButton.isHidden = infoButtonHandler == nil }} @@ -68,6 +73,7 @@ class SettingsHeaderView: UITableViewHeaderFooterView { for: .touchUpInside ) + collapseButton.isHidden = true collapseButton.addTarget( self, action: #selector(handleCollapseButton(_:)), diff --git a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ChipView/ChipContainerView.swift b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ChipView/ChipContainerView.swift index 8b67445b6f..6c31610be4 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ChipView/ChipContainerView.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ChipView/ChipContainerView.swift @@ -58,35 +58,37 @@ struct ChipContainerView<ViewModel>: View where ViewModel: ChipViewModelProtocol nonisolated(unsafe) var height = CGFloat.zero return ForEach(chips) { data in - ChipView(item: data) - .padding( - EdgeInsets( - top: verticalPadding, - leading: 0, - bottom: verticalPadding, - trailing: UIMetrics.FeatureIndicators.chipViewTrailingMargin - ) + ChipView(item: data) { + viewModel.onPressed(item: data) + } + .padding( + EdgeInsets( + top: verticalPadding, + leading: 0, + bottom: verticalPadding, + trailing: UIMetrics.FeatureIndicators.chipViewTrailingMargin ) - .alignmentGuide(.leading) { dimension in - if abs(width - dimension.width) > containerWidth { - width = 0 - height -= dimension.height - } - let result = width - if data.id == chips.last?.id { - width = 0 - } else { - width -= dimension.width - } - return result + ) + .alignmentGuide(.leading) { dimension in + if abs(width - dimension.width) > containerWidth { + width = 0 + height -= dimension.height } - .alignmentGuide(.top) { _ in - let result = height - if data.id == chips.last?.id { - height = 0 - } - return result + let result = width + if data.id == chips.last?.id { + width = 0 + } else { + width -= dimension.width } + return result + } + .alignmentGuide(.top) { _ in + let result = height + if data.id == chips.last?.id { + height = 0 + } + return result + } } } } diff --git a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ChipView/ChipFeature.swift b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ChipView/ChipFeature.swift index dd26aa654f..02cc17f5e7 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ChipView/ChipFeature.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ChipView/ChipFeature.swift @@ -13,12 +13,23 @@ import SwiftUI // to be able to fetch the string value at a later point (eg. in ChipViewModelProtocol, // when calculating the text widths of the chips). -protocol ChipFeature { +protocol ChipFeature: Identifiable { + var id: FeatureType { get } var isEnabled: Bool { get } var name: String { get } } +enum FeatureType { + case daita + case multihop + case quantumResistance + case obfuscation + case dns + case ipOverrides +} + struct DaitaFeature: ChipFeature { + let id: FeatureType = .daita let state: TunnelState var isEnabled: Bool { @@ -36,6 +47,7 @@ struct DaitaFeature: ChipFeature { } struct QuantumResistanceFeature: ChipFeature { + let id: FeatureType = .quantumResistance let state: TunnelState var isEnabled: Bool { @@ -53,6 +65,7 @@ struct QuantumResistanceFeature: ChipFeature { } struct MultihopFeature: ChipFeature { + let id: FeatureType = .multihop let state: TunnelState var isEnabled: Bool { state.isMultihop @@ -69,6 +82,7 @@ struct MultihopFeature: ChipFeature { } struct ObfuscationFeature: ChipFeature { + let id: FeatureType = .obfuscation let settings: LatestTunnelSettings let state: ObservedState @@ -100,6 +114,7 @@ struct ObfuscationFeature: ChipFeature { } struct DNSFeature: ChipFeature { + let id: FeatureType = .dns let settings: LatestTunnelSettings var isEnabled: Bool { @@ -126,6 +141,7 @@ struct DNSFeature: ChipFeature { } struct IPOverrideFeature: ChipFeature { + let id: FeatureType = .ipOverrides let state: TunnelState let overrides: [IPOverride] diff --git a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ChipView/ChipModel.swift b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ChipView/ChipModel.swift index 829459b15f..097bcbb552 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ChipView/ChipModel.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ChipView/ChipModel.swift @@ -10,6 +10,6 @@ import Foundation import SwiftUI struct ChipModel: Identifiable { - var id: String { name } + var id: FeatureType let name: String } diff --git a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ChipView/ChipView.swift b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ChipView/ChipView.swift index de9376cc13..f79743faf7 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ChipView/ChipView.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ChipView/ChipView.swift @@ -10,33 +10,38 @@ import SwiftUI struct ChipView: View { let item: ChipModel + let onPress: (() -> Void)? private let borderWidth: CGFloat = 1 var body: some View { - Text(item.name) - .font(.subheadline) - .lineLimit(1) - .foregroundStyle(UIColor.primaryTextColor.color) - .padding(.horizontal, UIMetrics.FeatureIndicators.chipViewHorisontalPadding) - .padding(.vertical, 4) - .background( - RoundedRectangle(cornerRadius: 8) - .stroke( - UIColor.primaryColor.color, - lineWidth: borderWidth - ) - .background( - RoundedRectangle(cornerRadius: 8) - .fill(UIColor.secondaryColor.color) - ) - .padding(borderWidth) - ) + Button { + onPress?() + } label: { + Text(item.name) + .font(.subheadline) + .lineLimit(1) + .foregroundStyle(UIColor.primaryTextColor.color) + .padding(.horizontal, UIMetrics.FeatureIndicators.chipViewHorisontalPadding) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 8) + .stroke( + UIColor.primaryColor.color, + lineWidth: borderWidth + ) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(UIColor.secondaryColor.color) + ) + .padding(borderWidth) + ) + } } } #Preview { ZStack { - ChipView(item: ChipModel(name: "Example")) + ChipView(item: ChipModel(id: .daita, name: "Example")) {} } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(UIColor.secondaryColor.color) diff --git a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ChipView/ChipViewModelProtocol.swift b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ChipView/ChipViewModelProtocol.swift index 6d2a6cfb2c..b5fbd83e06 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ChipView/ChipViewModelProtocol.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ChipView/ChipViewModelProtocol.swift @@ -10,6 +10,7 @@ import SwiftUI protocol ChipViewModelProtocol: ObservableObject { var chips: [ChipModel] { get } + func onPressed(item: ChipModel) } extension ChipViewModelProtocol { @@ -53,13 +54,15 @@ extension ChipViewModelProtocol { } class MockFeatureIndicatorsViewModel: ChipViewModelProtocol { + func onPressed(item: ChipModel) {} + @Published var chips: [ChipModel] = [ - ChipModel(name: "DAITA"), - ChipModel(name: "Obfuscation"), - ChipModel(name: "Quantum resistance"), - ChipModel(name: "Multihop"), - ChipModel(name: "DNS content blockers"), - ChipModel(name: "Custom DNS"), - ChipModel(name: "Server IP override"), + ChipModel(id: .daita, name: "DAITA"), + ChipModel(id: .obfuscation, name: "Obfuscation"), + ChipModel(id: .quantumResistance, name: "Quantum resistance"), + ChipModel(id: .multihop, name: "Multihop"), + ChipModel(id: .dns, name: "DNS content blockers"), + ChipModel(id: .dns, name: "Custom DNS"), + ChipModel(id: .ipOverrides, name: "Server IP override"), ] } diff --git a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/FeatureIndicatorsViewModel.swift b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/FeatureIndicatorsViewModel.swift index 5458efb7a8..5b2ca87561 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/FeatureIndicatorsViewModel.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/FeatureIndicatorsViewModel.swift @@ -15,7 +15,7 @@ class FeatureIndicatorsViewModel: ChipViewModelProtocol { @Published var ipOverrides: [IPOverride] @Published var tunnelState: TunnelState @Published var observedState: ObservedState - + var onFeaturePressed: ((FeatureType) -> Void)? init( tunnelSettings: LatestTunnelSettings, ipOverrides: [IPOverride], @@ -33,7 +33,7 @@ class FeatureIndicatorsViewModel: ChipViewModelProtocol { switch tunnelState { case .connecting, .reconnecting, .negotiatingEphemeralPeer, .connected, .pendingReconnect: - let features: [ChipFeature] = [ + let features: [any ChipFeature] = [ DaitaFeature(state: tunnelState), QuantumResistanceFeature(state: tunnelState), MultihopFeature(state: tunnelState), @@ -44,9 +44,13 @@ class FeatureIndicatorsViewModel: ChipViewModelProtocol { return features .filter { $0.isEnabled } - .map { ChipModel(name: $0.name) } + .map { ChipModel(id: $0.id, name: $0.name) } default: return [] } } + + func onPressed(item: ChipModel) { + onFeaturePressed?(item.id) + } } diff --git a/ios/MullvadVPN/View controllers/Tunnel/TunnelViewController.swift b/ios/MullvadVPN/View controllers/Tunnel/TunnelViewController.swift index fa81a6cbbd..6c3c0332a9 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/TunnelViewController.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/TunnelViewController.swift @@ -25,6 +25,7 @@ class TunnelViewController: UIViewController, RootContainment { var shouldShowSelectLocationPicker: (() -> Void)? var shouldShowCancelTunnelAlert: (() -> Void)? + var shouldShowSettingsForFeature: ((FeatureType) -> Void)? let activityIndicator: SpinnerActivityIndicatorView = { let activityIndicator = SpinnerActivityIndicatorView(style: .large) @@ -91,6 +92,9 @@ class TunnelViewController: UIViewController, RootContainment { override func viewDidLoad() { super.viewDidLoad() + indicatorsViewViewModel.onFeaturePressed = { [weak self] feature in + self?.shouldShowSettingsForFeature?(feature) + } interactor.didUpdateDeviceState = { [weak self] _, _ in self?.setNeedsHeaderBarStyleAppearanceUpdate() diff --git a/ios/MullvadVPN/View controllers/VPNSettings/CustomDNSCoordinator.swift b/ios/MullvadVPN/View controllers/VPNSettings/CustomDNSCoordinator.swift new file mode 100644 index 0000000000..b7da14dc1d --- /dev/null +++ b/ios/MullvadVPN/View controllers/VPNSettings/CustomDNSCoordinator.swift @@ -0,0 +1,42 @@ +import Routing +import UIKit + +class CustomDNSCoordinator: Coordinator, Presentable, Presenting { + private let navigationController: UINavigationController + private let interactor: VPNSettingsInteractor + private let route: AppRoute + + var didFinish: ((CustomDNSCoordinator) -> Void)? + + var presentedViewController: UIViewController { + navigationController + } + + init(navigationController: UINavigationController, interactor: VPNSettingsInteractor, route: AppRoute) { + self.interactor = interactor + self.navigationController = navigationController + self.route = route + } + + func start(animated: Bool) { + let alertPresenter = AlertPresenter(context: self) + let viewController = CustomDNSViewController(interactor: interactor, alertPresenter: alertPresenter) + customiseNavigation(on: viewController) + navigationController.pushViewController(viewController, animated: animated) + } + + private func customiseNavigation(on viewController: UIViewController) { + if route == .dnsSettings { + navigationController.navigationItem.largeTitleDisplayMode = .always + navigationController.navigationBar.prefersLargeTitles = true + let doneButton = UIBarButtonItem( + systemItem: .done, + primaryAction: UIAction(handler: { [weak self] _ in + guard let self else { return } + didFinish?(self) + }) + ) + viewController.navigationItem.rightBarButtonItem = doneButton + } + } +} diff --git a/ios/MullvadVPN/View controllers/VPNSettings/CustomDNSViewController.swift b/ios/MullvadVPN/View controllers/VPNSettings/CustomDNSViewController.swift index 60ab057b82..7b1227cfe0 100644 --- a/ios/MullvadVPN/View controllers/VPNSettings/CustomDNSViewController.swift +++ b/ios/MullvadVPN/View controllers/VPNSettings/CustomDNSViewController.swift @@ -48,9 +48,13 @@ class CustomDNSViewController: UITableViewController { value: "DNS settings", comment: "" ) - - navigationItem.rightBarButtonItem = editButtonItem - navigationItem.rightBarButtonItem?.setAccessibilityIdentifier(.dnsSettingsEditButton) + if navigationItem.rightBarButtonItem != nil { + navigationItem.leftBarButtonItem = editButtonItem + navigationItem.leftBarButtonItem?.setAccessibilityIdentifier(.dnsSettingsEditButton) + } else { + navigationItem.rightBarButtonItem = editButtonItem + navigationItem.rightBarButtonItem?.setAccessibilityIdentifier(.dnsSettingsEditButton) + } interactor.tunnelSettingsDidChange = { [weak self] newSettings in self?.dataSource?.update(from: newSettings) @@ -69,6 +73,13 @@ class CustomDNSViewController: UITableViewController { dataSource?.setEditing(editing, animated: animated) navigationItem.setHidesBackButton(editing, animated: animated) + if navigationItem.rightBarButtonItem != editButtonItem { + if #available(iOS 16.0, *) { + navigationItem.rightBarButtonItem?.isHidden = editing + } else { + navigationItem.rightBarButtonItem?.isEnabled = !editing + } + } // Disable swipe to dismiss when editing isModalInPresentation = editing diff --git a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSource.swift b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSource.swift index 75f5e974a7..84df606905 100644 --- a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSource.swift +++ b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSource.swift @@ -249,7 +249,12 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< ].compactMap { indexPath(for: $0) } } - init(tableView: UITableView) { + var onlyShowSection: Section? + + init( + tableView: UITableView, + section: VPNSettingsSection? = nil + ) { self.tableView = tableView let vpnSettingsCellFactory = VPNSettingsCellFactory( @@ -258,6 +263,12 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< ) self.vpnSettingsCellFactory = vpnSettingsCellFactory + self.onlyShowSection = switch section { + case .obfuscation: .wireGuardObfuscation + case .quantumResistance: .quantumResistance + default: nil + } + super.init(tableView: tableView) { _, indexPath, itemIdentifier in vpnSettingsCellFactory.makeCell(for: itemIdentifier, indexPath: indexPath) } @@ -506,22 +517,45 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< private func updateSnapshot(animated: Bool = false, completion: (() -> Void)? = nil) { var snapshot = NSDiffableDataSourceSnapshot<Section, Item>() - - snapshot.appendSections(Section.allCases) - snapshot.appendItems([.dnsSettings], toSection: .dnsSettings) - snapshot.appendItems([.ipOverrides], toSection: .ipOverrides) + if let onlyShowSection { + snapshot.appendSections([onlyShowSection]) + } else { + snapshot.appendSections(Section.allCases) + } + if snapshot.sectionIdentifiers.contains(.dnsSettings) { + snapshot.appendItems([.dnsSettings], toSection: .dnsSettings) + } + if snapshot.sectionIdentifiers.contains(.ipOverrides) { + snapshot.appendItems([.ipOverrides], toSection: .ipOverrides) + } #if DEBUG - snapshot.appendItems( - [.localNetworkSharing(viewModel.localNetworkSharing)], - toSection: .localNetworkSharing - ) - snapshot - .appendItems( - [.includeAllNetworks(viewModel.includeAllNetworks)], + if snapshot.sectionIdentifiers.contains(.localNetworkSharing) { + snapshot.appendItems( + [.localNetworkSharing(viewModel.localNetworkSharing)], toSection: .localNetworkSharing ) + snapshot + .appendItems( + [.includeAllNetworks(viewModel.includeAllNetworks)], + toSection: .localNetworkSharing + ) + } #endif + if onlyShowSection == .wireGuardObfuscation { + snapshot + .appendItems( + Item.wireGuardObfuscation, + toSection: .wireGuardObfuscation + ) + } else if onlyShowSection == .quantumResistance { + snapshot + .appendItems( + Item.quantumResistance, + toSection: .quantumResistance + ) + } + applySnapshot(snapshot, animated: animated, completion: completion) } @@ -599,17 +633,19 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< header.titleLabel.text = title header.accessibilityCustomActionName = title header.isExpanded = isExpanded(.wireGuardObfuscation) - header.didCollapseHandler = { [weak self] header in - guard let self else { return } + if onlyShowSection == nil || onlyShowSection != .wireGuardObfuscation { + header.didCollapseHandler = { [weak self] header in + guard let self else { return } - var snapshot = snapshot() - if header.isExpanded { - snapshot.deleteItems(Item.wireGuardObfuscation) - } else { - snapshot.appendItems(Item.wireGuardObfuscation, toSection: .wireGuardObfuscation) + var snapshot = snapshot() + if header.isExpanded { + snapshot.deleteItems(Item.wireGuardObfuscation) + } else { + snapshot.appendItems(Item.wireGuardObfuscation, toSection: .wireGuardObfuscation) + } + header.isExpanded.toggle() + applySnapshot(snapshot, animated: true) } - header.isExpanded.toggle() - applySnapshot(snapshot, animated: true) } header.infoButtonHandler = { [weak self] in @@ -629,19 +665,20 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< header.titleLabel.text = title header.accessibilityCustomActionName = title header.isExpanded = isExpanded(.quantumResistance) - header.didCollapseHandler = { [weak self] header in - guard let self else { return } + if onlyShowSection == nil || onlyShowSection != .quantumResistance { + header.didCollapseHandler = { [weak self] header in + guard let self else { return } - var snapshot = snapshot() - if header.isExpanded { - snapshot.deleteItems(Item.quantumResistance) - } else { - snapshot.appendItems(Item.quantumResistance, toSection: .quantumResistance) + var snapshot = snapshot() + if header.isExpanded { + snapshot.deleteItems(Item.quantumResistance) + } else { + snapshot.appendItems(Item.quantumResistance, toSection: .quantumResistance) + } + header.isExpanded.toggle() + applySnapshot(snapshot, animated: true) } - header.isExpanded.toggle() - applySnapshot(snapshot, animated: true) } - header.infoButtonHandler = { [weak self] in self.map { $0.delegate?.showInfo(for: .quantumResistance) } } diff --git a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsViewController.swift b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsViewController.swift index 551813209c..377cd76247 100644 --- a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsViewController.swift +++ b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsViewController.swift @@ -18,17 +18,21 @@ class VPNSettingsViewController: UITableViewController { private let interactor: VPNSettingsInteractor private var dataSource: VPNSettingsDataSource? private let alertPresenter: AlertPresenter - + private let section: VPNSettingsSection? weak var delegate: VPNSettingsViewControllerDelegate? override var preferredStatusBarStyle: UIStatusBarStyle { .lightContent } - init(interactor: VPNSettingsInteractor, alertPresenter: AlertPresenter) { + init( + interactor: VPNSettingsInteractor, + alertPresenter: AlertPresenter, + section: VPNSettingsSection? + ) { self.interactor = interactor self.alertPresenter = alertPresenter - + self.section = section super.init(style: .grouped) } @@ -47,7 +51,11 @@ class VPNSettingsViewController: UITableViewController { tableView.estimatedSectionHeaderHeight = tableView.estimatedRowHeight tableView.allowsMultipleSelection = true - dataSource = VPNSettingsDataSource(tableView: tableView) + dataSource = VPNSettingsDataSource( + tableView: tableView, + section: section + ) + dataSource?.delegate = self navigationItem.title = NSLocalizedString( @@ -67,9 +75,10 @@ class VPNSettingsViewController: UITableViewController { self?.dataSource?.setAvailablePortRanges(cachedRelays.relays.wireguard.portRanges) } + let showsSingleSection = section != nil tableView.tableHeaderView = UIView(frame: CGRect( origin: .zero, - size: CGSize(width: 0, height: UIMetrics.TableView.sectionSpacing) + size: CGSize(width: 0, height: showsSingleSection ? 0 : UIMetrics.TableView.sectionSpacing) )) } } |
