diff --git a/ios/MullvadMockData/MullvadREST/SelectedRelaysStub+Stubs.swift b/ios/MullvadMockData/MullvadREST/SelectedRelaysStub+Stubs.swift new file mode 100644 index 000000000000..adc749223000 --- /dev/null +++ b/ios/MullvadMockData/MullvadREST/SelectedRelaysStub+Stubs.swift @@ -0,0 +1,36 @@ +// +// SelectedRelaysStub+Stubs.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-12-18. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import MullvadREST +import MullvadTypes +import Network + +public struct SelectedRelaysStub { + public static let selectedRelays = SelectedRelays( + entry: nil, + exit: SelectedRelay( + endpoint: MullvadEndpoint( + ipv4Relay: IPv4Endpoint(ip: .loopback, port: 42), + ipv6Relay: IPv6Endpoint(ip: .loopback, port: 42), + ipv4Gateway: IPv4Address.loopback, + ipv6Gateway: IPv6Address.loopback, + publicKey: Data() + ), + hostname: "se-got-wg-001", + location: Location( + country: "Sweden", + countryCode: "se", + city: "Gothenburg", + cityCode: "got", + latitude: 42, + longitude: 42 + ) + ), + retryAttempt: 0 + ) +} diff --git a/ios/MullvadRustRuntime/EphemeralPeerNegotiator.swift b/ios/MullvadRustRuntime/EphemeralPeerNegotiator.swift index 8346b2686d45..85503c8bf468 100644 --- a/ios/MullvadRustRuntime/EphemeralPeerNegotiator.swift +++ b/ios/MullvadRustRuntime/EphemeralPeerNegotiator.swift @@ -11,7 +11,6 @@ import MullvadTypes import NetworkExtension import WireGuardKitTypes -// swiftlint:disable function_parameter_count public protocol EphemeralPeerNegotiating { func startNegotiation( devicePublicKey: PublicKey, @@ -70,5 +69,3 @@ public class EphemeralPeerNegotiator: EphemeralPeerNegotiating { drop_ephemeral_peer_exchange_token(cancelToken) } } - -// swiftlint:enable function_parameter_count diff --git a/ios/MullvadRustRuntimeTests/MullvadPostQuantum+Stubs.swift b/ios/MullvadRustRuntimeTests/MullvadPostQuantum+Stubs.swift index 52e4742c2126..ff7a3f74a4e3 100644 --- a/ios/MullvadRustRuntimeTests/MullvadPostQuantum+Stubs.swift +++ b/ios/MullvadRustRuntimeTests/MullvadPostQuantum+Stubs.swift @@ -12,7 +12,6 @@ import NetworkExtension @testable import PacketTunnelCore @testable import WireGuardKitTypes -// swiftlint:disable function_parameter_count class NWTCPConnectionStub: NWTCPConnection { var _isViable = false override var isViable: Bool { @@ -104,5 +103,3 @@ class SuccessfulNegotiatorStub: EphemeralPeerNegotiating { onCancelKeyNegotiation?() } } - -// swiftlint:enable function_parameter_count diff --git a/ios/MullvadSettings/IPOverrideRepository.swift b/ios/MullvadSettings/IPOverrideRepository.swift index 867a1c077fd5..441ff6c35e1e 100644 --- a/ios/MullvadSettings/IPOverrideRepository.swift +++ b/ios/MullvadSettings/IPOverrideRepository.swift @@ -6,10 +6,11 @@ // Copyright © 2024 Mullvad VPN AB. All rights reserved. // -import Foundation +import Combine import MullvadLogging public protocol IPOverrideRepositoryProtocol { + var overridesPublisher: AnyPublisher<[IPOverride], Never> { get } func add(_ overrides: [IPOverride]) func fetchAll() -> [IPOverride] func deleteAll() @@ -17,6 +18,11 @@ public protocol IPOverrideRepositoryProtocol { } public class IPOverrideRepository: IPOverrideRepositoryProtocol { + private let overridesSubject: CurrentValueSubject<[IPOverride], Never> = .init([]) + public var overridesPublisher: AnyPublisher<[IPOverride], Never> { + overridesSubject.eraseToAnyPublisher() + } + private let logger = Logger(label: "IPOverrideRepository") private let readWriteLock = NSLock() @@ -58,6 +64,7 @@ public class IPOverrideRepository: IPOverrideRepositoryProtocol { do { try readWriteLock.withLock { try SettingsManager.store.delete(key: .ipOverrides) + overridesSubject.send([]) } } catch { logger.error("Could not delete all overrides. \nError: \(error)") @@ -85,6 +92,7 @@ public class IPOverrideRepository: IPOverrideRepositoryProtocol { try readWriteLock.withLock { try SettingsManager.store.write(data, for: .ipOverrides) + overridesSubject.send(overrides) } } diff --git a/ios/MullvadSettings/WireGuardObfuscationSettings.swift b/ios/MullvadSettings/WireGuardObfuscationSettings.swift index f067114cc634..c52a637626fd 100644 --- a/ios/MullvadSettings/WireGuardObfuscationSettings.swift +++ b/ios/MullvadSettings/WireGuardObfuscationSettings.swift @@ -46,6 +46,10 @@ public enum WireGuardObfuscationState: Codable { self = .off } } + + public var isEnabled: Bool { + [.udpOverTcp, .shadowsocks].contains(self) + } } public enum WireGuardObfuscationUdpOverTcpPort: Codable, Equatable, CustomStringConvertible { diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index b5f13b415f86..e1cda675d735 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -43,6 +43,9 @@ 44075DFB2CDA4F7400F61139 /* UDPOverTCPObfuscationSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44075DFA2CDA4F7400F61139 /* UDPOverTCPObfuscationSettingsViewModel.swift */; }; 440E5AB02CDBD67D00B09614 /* StatefulPreviewWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 440E5AAF2CDBD67D00B09614 /* StatefulPreviewWrapper.swift */; }; 440E5AB42CDCF24500B09614 /* TunnelObfuscationSettingsWatchingObservableObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 440E5AB32CDCF24500B09614 /* TunnelObfuscationSettingsWatchingObservableObject.swift */; }; + 4419AA892D282687001B13C9 /* DetailsContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4419AA882D282687001B13C9 /* DetailsContainer.swift */; }; + 4419AA8B2D2826E5001B13C9 /* DetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4419AA8A2D2826E5001B13C9 /* DetailsView.swift */; }; + 4419AA8E2D2828A4001B13C9 /* HeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4419AA8D2D2828A4001B13C9 /* HeaderView.swift */; }; 4422C0712CCFF6790001A385 /* UDPOverTCPObfuscationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4422C0702CCFF6790001A385 /* UDPOverTCPObfuscationSettingsView.swift */; }; 4424CDD32CDBD4A6009D8C9F /* SingleChoiceList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4424CDD22CDBD4A6009D8C9F /* SingleChoiceList.swift */; }; 447F3D8A2CDE1853006E3462 /* ShadowsocksObfuscationSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 447F3D882CDE1852006E3462 /* ShadowsocksObfuscationSettingsViewModel.swift */; }; @@ -52,6 +55,8 @@ 4495ECD52D131A4800A7358B /* ShadowsocksObfuscationSettingsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4495ECD42D131A3E00A7358B /* ShadowsocksObfuscationSettingsPage.swift */; }; 449872E12B7BBC5400094DDC /* TunnelSettingsUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 449872E02B7BBC5400094DDC /* TunnelSettingsUpdate.swift */; }; 449872E42B7CB96300094DDC /* TunnelSettingsUpdateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 449872E32B7CB96300094DDC /* TunnelSettingsUpdateTests.swift */; }; + 449E9A6D2D283A2500F8574A /* ConnectionViewComponentPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 449E9A6C2D283A2500F8574A /* ConnectionViewComponentPreview.swift */; }; + 449E9A6F2D283C7400F8574A /* ButtonPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 449E9A6E2D283C7400F8574A /* ButtonPanel.swift */; }; 449EBA262B975B9700DFA4EB /* EphemeralPeerReceiving.swift in Sources */ = {isa = PBXBuildFile; fileRef = 449EBA252B975B9700DFA4EB /* EphemeralPeerReceiving.swift */; }; 44B02E3B2BC5732D008EDF34 /* LoggingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44B02E3A2BC5732D008EDF34 /* LoggingTests.swift */; }; 44B02E3C2BC5B8A5008EDF34 /* Bundle+ProductVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5891BF1B25E3E3EB006D6FB0 /* Bundle+ProductVersion.swift */; }; @@ -616,6 +621,7 @@ 7AA1309F2D007B2500640DF9 /* VisualEffectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA1309E2D007B2500640DF9 /* VisualEffectView.swift */; }; 7AA130A12D01B1E200640DF9 /* SplitMainButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA130A02D01B1E200640DF9 /* SplitMainButton.swift */; }; 7AA513862BC91C6B00D081A4 /* LogRotationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA513852BC91C6B00D081A4 /* LogRotationTests.swift */; }; + 7AA636382D2D3BB0009B2C89 /* View+Conditionals.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA636372D2D3BAC009B2C89 /* View+Conditionals.swift */; }; 7AA7046A2C8EFE2B0045699D /* StoredRelays.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA704682C8EFE050045699D /* StoredRelays.swift */; }; 7AB2B6702BA1EB8C00B03E3B /* ListCustomListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AB2B66E2BA1EB8C00B03E3B /* ListCustomListViewController.swift */; }; 7AB2B6712BA1EB8C00B03E3B /* ListCustomListCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AB2B66F2BA1EB8C00B03E3B /* ListCustomListCoordinator.swift */; }; @@ -655,6 +661,7 @@ 7AF10EB42ADE85BC00C090B9 /* RelayFilterCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF10EB32ADE85BC00C090B9 /* RelayFilterCoordinator.swift */; }; 7AF36A9A2CA2964200E1D497 /* AnyIPAddressTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF36A992CA2964000E1D497 /* AnyIPAddressTests.swift */; }; 7AF6E5F02A95051E00F2679D /* RouterBlockDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF6E5EF2A95051E00F2679D /* RouterBlockDelegate.swift */; }; + 7AF84F462D12C5B000C72690 /* SelectedRelaysStub+Stubs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF84F452D12C59F00C72690 /* SelectedRelaysStub+Stubs.swift */; }; 7AF9BE882A30C62100DBFEDB /* SelectableSettingsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1A264A2A29D65E00B978AA /* SelectableSettingsCell.swift */; }; 7AF9BE8C2A321D1F00DBFEDB /* RelayFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF9BE8A2A321BEF00DBFEDB /* RelayFilter.swift */; }; 7AF9BE8E2A331C7B00DBFEDB /* RelayFilterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF9BE8D2A331C7B00DBFEDB /* RelayFilterViewModel.swift */; }; @@ -1001,7 +1008,14 @@ F0ADC3722CD3AD1600A1AD97 /* ChipCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0ADC3712CD3AD1600A1AD97 /* ChipCollectionView.swift */; }; F0ADC3742CD3C47400A1AD97 /* ChipFlowLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0ADC3732CD3C47400A1AD97 /* ChipFlowLayout.swift */; }; F0ADF1CD2CFDFF3100299F09 /* StringConversionError.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0ADF1CC2CFDFF3100299F09 /* StringConversionError.swift */; }; + F0ADF1D12D01B55C00299F09 /* ChipModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0ADF1D02D01B55C00299F09 /* ChipModel.swift */; }; + F0ADF1D32D01B6B400299F09 /* FeatureIndicatorsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0ADF1D22D01B6B400299F09 /* FeatureIndicatorsViewModel.swift */; }; + F0ADF1D52D01DCFD00299F09 /* ChipView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0ADF1D42D01DCFD00299F09 /* ChipView.swift */; }; F0B0E6972AFE6E7E001DC66B /* XCTest+Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B0E6962AFE6E7E001DC66B /* XCTest+Async.swift */; }; + F0B495762D02025200CFEC2A /* ChipContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B495752D02025200CFEC2A /* ChipContainerView.swift */; }; + F0B495782D02038B00CFEC2A /* ChipViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B495772D02038B00CFEC2A /* ChipViewModelProtocol.swift */; }; + F0B4957A2D02F49200CFEC2A /* ChipFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B495792D02F41F00CFEC2A /* ChipFeature.swift */; }; + F0B4957C2D03154200CFEC2A /* FeatureIndicatorsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B4957B2D03154200CFEC2A /* FeatureIndicatorsView.swift */; }; F0B894EF2BF751C500817A42 /* RelayWithLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B894EE2BF751C500817A42 /* RelayWithLocation.swift */; }; F0B894F12BF751E300817A42 /* RelayWithDistance.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B894F02BF751E300817A42 /* RelayWithDistance.swift */; }; F0B894F32BF7526700817A42 /* RelaySelector+Wireguard.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B894F22BF7526700817A42 /* RelaySelector+Wireguard.swift */; }; @@ -1434,6 +1448,9 @@ 44075DFA2CDA4F7400F61139 /* UDPOverTCPObfuscationSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UDPOverTCPObfuscationSettingsViewModel.swift; sourceTree = ""; }; 440E5AAF2CDBD67D00B09614 /* StatefulPreviewWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatefulPreviewWrapper.swift; sourceTree = ""; }; 440E5AB32CDCF24500B09614 /* TunnelObfuscationSettingsWatchingObservableObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelObfuscationSettingsWatchingObservableObject.swift; sourceTree = ""; }; + 4419AA882D282687001B13C9 /* DetailsContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailsContainer.swift; sourceTree = ""; }; + 4419AA8A2D2826E5001B13C9 /* DetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailsView.swift; sourceTree = ""; }; + 4419AA8D2D2828A4001B13C9 /* HeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderView.swift; sourceTree = ""; }; 4422C0702CCFF6790001A385 /* UDPOverTCPObfuscationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UDPOverTCPObfuscationSettingsView.swift; sourceTree = ""; }; 4424CDD22CDBD4A6009D8C9F /* SingleChoiceList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleChoiceList.swift; sourceTree = ""; }; 447F3D882CDE1852006E3462 /* ShadowsocksObfuscationSettingsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShadowsocksObfuscationSettingsViewModel.swift; sourceTree = ""; }; @@ -1444,6 +1461,8 @@ 4495ECD42D131A3E00A7358B /* ShadowsocksObfuscationSettingsPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShadowsocksObfuscationSettingsPage.swift; sourceTree = ""; }; 449872E02B7BBC5400094DDC /* TunnelSettingsUpdate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelSettingsUpdate.swift; sourceTree = ""; }; 449872E32B7CB96300094DDC /* TunnelSettingsUpdateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelSettingsUpdateTests.swift; sourceTree = ""; }; + 449E9A6C2D283A2500F8574A /* ConnectionViewComponentPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionViewComponentPreview.swift; sourceTree = ""; }; + 449E9A6E2D283C7400F8574A /* ButtonPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonPanel.swift; sourceTree = ""; }; 449EB9FC2B95F8AD00DFA4EB /* DeviceMock.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeviceMock.swift; sourceTree = ""; }; 449EB9FE2B95FF2500DFA4EB /* AccountMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountMock.swift; sourceTree = ""; }; 449EBA252B975B9700DFA4EB /* EphemeralPeerReceiving.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EphemeralPeerReceiving.swift; sourceTree = ""; }; @@ -1989,6 +2008,7 @@ 7AA1309E2D007B2500640DF9 /* VisualEffectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualEffectView.swift; sourceTree = ""; }; 7AA130A02D01B1E200640DF9 /* SplitMainButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitMainButton.swift; sourceTree = ""; }; 7AA513852BC91C6B00D081A4 /* LogRotationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogRotationTests.swift; sourceTree = ""; }; + 7AA636372D2D3BAC009B2C89 /* View+Conditionals.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Conditionals.swift"; sourceTree = ""; }; 7AA704682C8EFE050045699D /* StoredRelays.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredRelays.swift; sourceTree = ""; }; 7AB2B66E2BA1EB8C00B03E3B /* ListCustomListViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ListCustomListViewController.swift; sourceTree = ""; }; 7AB2B66F2BA1EB8C00B03E3B /* ListCustomListCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ListCustomListCoordinator.swift; sourceTree = ""; }; @@ -2021,6 +2041,7 @@ 7AF10EB32ADE85BC00C090B9 /* RelayFilterCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RelayFilterCoordinator.swift; sourceTree = ""; }; 7AF36A992CA2964000E1D497 /* AnyIPAddressTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyIPAddressTests.swift; sourceTree = ""; }; 7AF6E5EF2A95051E00F2679D /* RouterBlockDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouterBlockDelegate.swift; sourceTree = ""; }; + 7AF84F452D12C59F00C72690 /* SelectedRelaysStub+Stubs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SelectedRelaysStub+Stubs.swift"; sourceTree = ""; }; 7AF9BE8A2A321BEF00DBFEDB /* RelayFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayFilter.swift; sourceTree = ""; }; 7AF9BE8D2A331C7B00DBFEDB /* RelayFilterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayFilterViewModel.swift; sourceTree = ""; }; 7AF9BE8F2A39F26000DBFEDB /* Collection+Sorting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Collection+Sorting.swift"; sourceTree = ""; }; @@ -2244,7 +2265,14 @@ F0ADC3712CD3AD1600A1AD97 /* ChipCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChipCollectionView.swift; sourceTree = ""; }; F0ADC3732CD3C47400A1AD97 /* ChipFlowLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChipFlowLayout.swift; sourceTree = ""; }; F0ADF1CC2CFDFF3100299F09 /* StringConversionError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringConversionError.swift; sourceTree = ""; }; + F0ADF1D02D01B55C00299F09 /* ChipModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChipModel.swift; sourceTree = ""; }; + F0ADF1D22D01B6B400299F09 /* FeatureIndicatorsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureIndicatorsViewModel.swift; sourceTree = ""; }; + F0ADF1D42D01DCFD00299F09 /* ChipView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChipView.swift; sourceTree = ""; }; F0B0E6962AFE6E7E001DC66B /* XCTest+Async.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCTest+Async.swift"; sourceTree = ""; }; + F0B495752D02025200CFEC2A /* ChipContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChipContainerView.swift; sourceTree = ""; }; + F0B495772D02038B00CFEC2A /* ChipViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChipViewModelProtocol.swift; sourceTree = ""; }; + F0B495792D02F41F00CFEC2A /* ChipFeature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChipFeature.swift; sourceTree = ""; }; + F0B4957B2D03154200CFEC2A /* FeatureIndicatorsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureIndicatorsView.swift; sourceTree = ""; }; F0B894EE2BF751C500817A42 /* RelayWithLocation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayWithLocation.swift; sourceTree = ""; }; F0B894F02BF751E300817A42 /* RelayWithDistance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayWithDistance.swift; sourceTree = ""; }; F0B894F22BF7526700817A42 /* RelaySelector+Wireguard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RelaySelector+Wireguard.swift"; sourceTree = ""; }; @@ -2676,6 +2704,21 @@ path = Protocols; sourceTree = ""; }; + 4419AA862D28264D001B13C9 /* ConnectionView */ = { + isa = PBXGroup; + children = ( + F0ADF1CF2D01B50B00299F09 /* ChipView */, + 449E9A6E2D283C7400F8574A /* ButtonPanel.swift */, + 7AA130982CFF365A00640DF9 /* ConnectionView.swift */, + 449E9A6C2D283A2500F8574A /* ConnectionViewComponentPreview.swift */, + 7A0EAEA32D06DF8200D3EB8B /* ConnectionViewViewModel.swift */, + 4419AA882D282687001B13C9 /* DetailsContainer.swift */, + 4419AA8A2D2826E5001B13C9 /* DetailsView.swift */, + 4419AA8D2D2828A4001B13C9 /* HeaderView.swift */, + ); + path = ConnectionView; + sourceTree = ""; + }; 4422C06F2CCFF6520001A385 /* Obfuscation */ = { isa = PBXGroup; children = ( @@ -3131,6 +3174,7 @@ 7A21DACE2A30AA3700A787A9 /* UITextField+Appearance.swift */, 5878F4FF29CDA742003D4BE2 /* UIView+AutoLayoutBuilder.swift */, 7A516C2D2B6D357500BBD33D /* URL+Scoping.swift */, + 7AA636372D2D3BAC009B2C89 /* View+Conditionals.swift */, 7A0EAE9D2D01BCBF00D3EB8B /* View+Size.swift */, 7A8A18FA2CE4B66C000BCB5B /* View+TapAreaSize.swift */, ); @@ -3634,7 +3678,6 @@ A9D9A4C12C36D53C004088DD /* MullvadRustRuntimeTests */, 58CE5E61224146200008646E /* Products */, 584F991F2902CBDD001F858D /* Frameworks */, - 7A0EAE982D01B29E00D3EB8B /* Recovered References */, ); sourceTree = ""; }; @@ -3935,13 +3978,6 @@ path = Edit; sourceTree = ""; }; - 7A0EAE982D01B29E00D3EB8B /* Recovered References */ = { - isa = PBXGroup; - children = ( - ); - name = "Recovered References"; - sourceTree = ""; - }; 7A2960F72A964A3500389B82 /* Alert */ = { isa = PBXGroup; children = ( @@ -4076,9 +4112,10 @@ 7AA130972CFF364F00640DF9 /* FeatureIndicators */ = { isa = PBXGroup; children = ( + 4419AA862D28264D001B13C9 /* ConnectionView */, 7AFBE3862D084C96002335FC /* ActivityIndicator.swift */, - 7AA130982CFF365A00640DF9 /* ConnectionView.swift */, - 7A0EAEA32D06DF8200D3EB8B /* ConnectionViewViewModel.swift */, + F0B4957B2D03154200CFEC2A /* FeatureIndicatorsView.swift */, + F0ADF1D22D01B6B400299F09 /* FeatureIndicatorsViewModel.swift */, 7AFBE3882D08915D002335FC /* FI_TunnelViewController.swift */, ); path = FeatureIndicators; @@ -4390,6 +4427,7 @@ F0ACE32E2BE4EA8B006D5333 /* MockProxyFactory.swift */, 58FE25EF2AA77664003D1918 /* RelaySelectorStub.swift */, A900E9B92ACC5D0600C95F67 /* RESTRequestExecutor+Stubs.swift */, + 7AF84F452D12C59F00C72690 /* SelectedRelaysStub+Stubs.swift */, ); path = MullvadREST; sourceTree = ""; @@ -4403,6 +4441,18 @@ path = MullvadTypes; sourceTree = ""; }; + F0ADF1CF2D01B50B00299F09 /* ChipView */ = { + isa = PBXGroup; + children = ( + F0B495752D02025200CFEC2A /* ChipContainerView.swift */, + F0B495792D02F41F00CFEC2A /* ChipFeature.swift */, + F0ADF1D02D01B55C00299F09 /* ChipModel.swift */, + F0ADF1D42D01DCFD00299F09 /* ChipView.swift */, + F0B495772D02038B00CFEC2A /* ChipViewModelProtocol.swift */, + ); + path = ChipView; + sourceTree = ""; + }; F0DC779F2B2222D20087F09D /* Relay */ = { isa = PBXGroup; children = ( @@ -5881,6 +5931,7 @@ 58421030282D8A3C00F24E46 /* UpdateAccountDataOperation.swift in Sources */, 7A8A190A2CE5FFE9000BCB5B /* SettingsDAITAView.swift in Sources */, F0E8E4C92A604E7400ED26A3 /* AccountDeletionInteractor.swift in Sources */, + 449E9A6D2D283A2500F8574A /* ConnectionViewComponentPreview.swift in Sources */, 7A5869952B32E9C700640D27 /* LinkButton.swift in Sources */, F09A297D2A9F8A9B00EA3B6F /* RedeemVoucherContentView.swift in Sources */, 5803B4B02940A47300C23744 /* TunnelConfiguration.swift in Sources */, @@ -5899,6 +5950,7 @@ F01DAE332C2B032A00521E46 /* RelaySelection.swift in Sources */, 58B993B12608A34500BA7811 /* LoginContentView.swift in Sources */, 7A516C2E2B6D357500BBD33D /* URL+Scoping.swift in Sources */, + 7AA636382D2D3BB0009B2C89 /* View+Conditionals.swift in Sources */, 5878A27529093A310096FC88 /* StorePaymentEvent.swift in Sources */, 7A7AD28D29DC677800480EF1 /* FirstTimeLaunch.swift in Sources */, F062000C2CB7EB5D002E6DB9 /* UIImage+Helpers.swift in Sources */, @@ -5928,6 +5980,7 @@ 5878A27129091CF20096FC88 /* AccountInteractor.swift in Sources */, 7AF9BE882A30C62100DBFEDB /* SelectableSettingsCell.swift in Sources */, 58CCA010224249A1004F3011 /* TunnelViewController.swift in Sources */, + F0B495782D02038B00CFEC2A /* ChipViewModelProtocol.swift in Sources */, 58CEB30A2AFD584700E6E088 /* CustomCellDisclosureHandling.swift in Sources */, 58B26E22294351EA00D5980C /* InAppNotificationProvider.swift in Sources */, 5893716A28817A45004EE76C /* DeviceManagementViewController.swift in Sources */, @@ -6002,6 +6055,7 @@ 58293FB3251241B4005D0BB5 /* CustomTextView.swift in Sources */, 586A950E290125F3007BAF2B /* ProductsRequestOperation.swift in Sources */, 7AF9BE902A39F26000DBFEDB /* Collection+Sorting.swift in Sources */, + F0B495762D02025200CFEC2A /* ChipContainerView.swift in Sources */, 58F19E35228C15BA00C7710B /* SpinnerActivityIndicatorView.swift in Sources */, 7A0EAE9A2D01B41500D3EB8B /* MainButtonStyle.swift in Sources */, 58CEB3022AFD365600E6E088 /* SwitchCellContentConfiguration.swift in Sources */, @@ -6012,6 +6066,7 @@ 7A6F2FAB2AFD3097006D0856 /* CustomDNSCellFactory.swift in Sources */, 58A99ED3240014A0006599E9 /* TermsOfServiceViewController.swift in Sources */, 7A6000FE2B628E9F001CF0D9 /* ListCellContentView.swift in Sources */, + 4419AA8B2D2826E5001B13C9 /* DetailsView.swift in Sources */, 58CCA0162242560B004F3011 /* UIColor+Palette.swift in Sources */, 587CBFE322807F530028DED3 /* UIColor+Helpers.swift in Sources */, 7A9CCCBE2A96302800DD6A34 /* AccountDeletionCoordinator.swift in Sources */, @@ -6075,6 +6130,7 @@ 587EB66A270EFACB00123C75 /* CharacterSet+IPAddress.swift in Sources */, 5888AD83227B11080051EB06 /* LocationCell.swift in Sources */, 5891BF1C25E3E3EB006D6FB0 /* Bundle+ProductVersion.swift in Sources */, + 4419AA892D282687001B13C9 /* DetailsContainer.swift in Sources */, 5878A26F2907E7E00096FC88 /* ProblemReportInteractor.swift in Sources */, 7AB4CCBB2B691BBB006037F5 /* IPOverrideInteractor.swift in Sources */, 7A3353912AAA014400F0A71C /* SimulatorVPNConnection.swift in Sources */, @@ -6111,6 +6167,7 @@ 588D7EDE2AF3A585005DF40A /* ListAccessMethodItem.swift in Sources */, 5827B0B02B0F4CCD00CCBBA1 /* ListAccessMethodViewControllerDelegate.swift in Sources */, 588D7EE02AF3A595005DF40A /* ListAccessMethodInteractor.swift in Sources */, + F0B4957A2D02F49200CFEC2A /* ChipFeature.swift in Sources */, 58607A4D2947287800BC467D /* AccountExpiryInAppNotificationProvider.swift in Sources */, 7A8A18FD2CE4BE8D000BCB5B /* CustomToggleStyle.swift in Sources */, 58C8191829FAA2C400DEB1B4 /* NotificationConfiguration.swift in Sources */, @@ -6125,6 +6182,8 @@ 58CE5E64224146200008646E /* AppDelegate.swift in Sources */, F0DA87492A9CBA9F006044F1 /* AccountDeviceRow.swift in Sources */, 58FF9FE42B075BDD00E4C97D /* EditAccessMethodItemIdentifier.swift in Sources */, + 449E9A6F2D283C7400F8574A /* ButtonPanel.swift in Sources */, + 4419AA8E2D2828A4001B13C9 /* HeaderView.swift in Sources */, 5878A27329091D6D0096FC88 /* TunnelBlockObserver.swift in Sources */, 7A27E3D12CC299F90088BCFF /* VPNSettingsDetailsButtonItem.swift in Sources */, A9E034642ABB302000E59A5A /* UIEdgeInsets+Extensions.swift in Sources */, @@ -6145,8 +6204,10 @@ 586C0D782B039CC000E7CDD7 /* AccessMethodProtocolPicker.swift in Sources */, 58677710290975E9006F721F /* SettingsInteractorFactory.swift in Sources */, 7A9CCCC02A96302800DD6A34 /* ProfileVoucherCoordinator.swift in Sources */, + F0B4957C2D03154200CFEC2A /* FeatureIndicatorsView.swift in Sources */, 7A9CCCBC2A96302800DD6A34 /* ChangeLogCoordinator.swift in Sources */, 58B26E282943527300D5980C /* SystemNotificationProvider.swift in Sources */, + F0ADF1D52D01DCFD00299F09 /* ChipView.swift in Sources */, 586C0D932B03D90700E7CDD7 /* ShadowsocksItemIdentifier.swift in Sources */, 58EFC7712AFB45E500E9F4CB /* SettingsChildCoordinator.swift in Sources */, 7A8A19102CEE391B000BCB5B /* RowSeparator.swift in Sources */, @@ -6190,6 +6251,7 @@ 7A9CCCC22A96302800DD6A34 /* SafariCoordinator.swift in Sources */, 58CEB3082AFD484100E6E088 /* BasicCell.swift in Sources */, 7A5869C12B57D21A00640D27 /* IPOverrideStatusView.swift in Sources */, + F0ADF1D32D01B6B400299F09 /* FeatureIndicatorsViewModel.swift in Sources */, 58CEB2F52AFD0BB500E6E088 /* TextCellContentConfiguration.swift in Sources */, 58E20771274672CA00DE5D77 /* LaunchViewController.swift in Sources */, F0E8CC032A4C753B007ED3B4 /* WelcomeViewController.swift in Sources */, @@ -6209,6 +6271,7 @@ A99E5EE02B7628150033F241 /* ProblemReportViewModel.swift in Sources */, 58FD5BF024238EB300112C88 /* SKProduct+Formatting.swift in Sources */, 58B43C1925F77DB60002C8C3 /* TunnelControlView.swift in Sources */, + F0ADF1D12D01B55C00299F09 /* ChipModel.swift in Sources */, F09A297B2A9F8A9B00EA3B6F /* LogoutDialogueView.swift in Sources */, 58CEB2FB2AFD13E600E6E088 /* UIListContentConfiguration+Extensions.swift in Sources */, 5811DE50239014550011EB53 /* NEVPNStatus+Debug.swift in Sources */, @@ -6494,6 +6557,7 @@ F0ACE3332BE516F1006D5333 /* RESTRequestExecutor+Stubs.swift in Sources */, F0ACE32D2BE4E784006D5333 /* AccountMock.swift in Sources */, 7A52F96A2C1735AE00B133B9 /* RelaySelectorStub.swift in Sources */, + 7AF84F462D12C5B000C72690 /* SelectedRelaysStub+Stubs.swift in Sources */, F03A69F72C2AD2D6000E2E7E /* TimeInterval+Timeout.swift in Sources */, F0ACE32F2BE4EA8B006D5333 /* MockProxyFactory.swift in Sources */, ); diff --git a/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift b/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift index 60b0c22fe942..73d9a2d14011 100644 --- a/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift +++ b/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift @@ -40,7 +40,7 @@ public enum AccessibilityIdentifier: Equatable { case purchaseButton case redeemVoucherButton case restorePurchasesButton - case secureConnectionButton + case connectButton case selectLocationButton case closeSelectLocationButton case settingsButton @@ -132,7 +132,7 @@ public enum AccessibilityIdentifier: Equatable { case selectLocationTableView case settingsTableView case vpnSettingsTableView - case tunnelControlView + case connectionView case problemReportView case problemReportSubmittedView case revokedDeviceView @@ -156,6 +156,7 @@ public enum AccessibilityIdentifier: Equatable { case logOutSpinnerAlertView case connectionPanelInAddressRow case connectionPanelOutAddressRow + case connectionPanelOutIpv6AddressRow case customSwitch case customWireGuardPortTextField case dnsContentBlockersHeaderView diff --git a/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift b/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift index d7e5dd557b5f..6fbb0690cb50 100644 --- a/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift @@ -486,7 +486,8 @@ final class ApplicationCoordinator: Coordinator, Presenting, RootContainerViewCo private func makeTunnelCoordinator() -> TunnelCoordinator { let tunnelCoordinator = TunnelCoordinator( tunnelManager: tunnelManager, - outgoingConnectionService: outgoingConnectionService + outgoingConnectionService: outgoingConnectionService, + ipOverrideRepository: ipOverrideRepository ) tunnelCoordinator.showSelectLocationPicker = { [weak self] in diff --git a/ios/MullvadVPN/Coordinators/TunnelCoordinator.swift b/ios/MullvadVPN/Coordinators/TunnelCoordinator.swift index 42f8fba106e6..0c55f7e1af46 100644 --- a/ios/MullvadVPN/Coordinators/TunnelCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/TunnelCoordinator.swift @@ -6,12 +6,18 @@ // Copyright © 2023 Mullvad VPN AB. All rights reserved. // +import MullvadSettings import Routing import UIKit class TunnelCoordinator: Coordinator, Presenting { private let tunnelManager: TunnelManager + + #if DEBUG + private let controller: FI_TunnelViewController + #else private let controller: TunnelViewController + #endif private var tunnelObserver: TunnelObserver? @@ -27,16 +33,22 @@ class TunnelCoordinator: Coordinator, Presenting { init( tunnelManager: TunnelManager, - outgoingConnectionService: OutgoingConnectionServiceHandling + outgoingConnectionService: OutgoingConnectionServiceHandling, + ipOverrideRepository: IPOverrideRepositoryProtocol ) { self.tunnelManager = tunnelManager let interactor = TunnelViewControllerInteractor( tunnelManager: tunnelManager, - outgoingConnectionService: outgoingConnectionService + outgoingConnectionService: outgoingConnectionService, + ipOverrideRepository: ipOverrideRepository ) + #if DEBUG + controller = FI_TunnelViewController(interactor: interactor) + #else controller = TunnelViewController(interactor: interactor) + #endif super.init() diff --git a/ios/MullvadVPN/Extensions/String+Helpers.swift b/ios/MullvadVPN/Extensions/String+Helpers.swift index a3112819405b..512adaa6f766 100644 --- a/ios/MullvadVPN/Extensions/String+Helpers.swift +++ b/ios/MullvadVPN/Extensions/String+Helpers.swift @@ -6,7 +6,6 @@ // Copyright © 2020 Mullvad VPN AB. All rights reserved. // -import Foundation import UIKit extension String { @@ -19,4 +18,9 @@ extension String { return (0 ..< resultCount) .map { dropFirst($0 * length).prefix(length) } } + + func width(using font: UIFont) -> CGFloat { + let fontAttributes = [NSAttributedString.Key.font: font] + return self.size(withAttributes: fontAttributes).width + } } diff --git a/ios/MullvadVPN/Extensions/View+Conditionals.swift b/ios/MullvadVPN/Extensions/View+Conditionals.swift new file mode 100644 index 000000000000..39e4405da8e6 --- /dev/null +++ b/ios/MullvadVPN/Extensions/View+Conditionals.swift @@ -0,0 +1,41 @@ +// +// View+Conditionals.swift +// MullvadVPN +// +// Created by Jon Petersson on 2025-01-07. +// Copyright © 2025 Mullvad VPN AB. All rights reserved. +// + +import SwiftUI + +extension View { + @ViewBuilder func `if`( + _ conditional: Bool, + @ViewBuilder _ content: (Self) -> Content + ) -> some View { + if conditional { + content(self) + } else { + self + } + } + + @ViewBuilder func ifLet( + _ conditional: T?, + @ViewBuilder _ content: (Self, _ value: T) -> Content + ) -> some View { + if let value = conditional { + content(self, value) + } else { + self + } + } + + @ViewBuilder func showIf(_ conditional: Bool) -> some View { + if conditional { + self + } else { + EmptyView() + } + } +} diff --git a/ios/MullvadVPN/Extensions/View+TapAreaSize.swift b/ios/MullvadVPN/Extensions/View+TapAreaSize.swift index 1e4ed64d3790..c9ab7ee14422 100644 --- a/ios/MullvadVPN/Extensions/View+TapAreaSize.swift +++ b/ios/MullvadVPN/Extensions/View+TapAreaSize.swift @@ -17,8 +17,8 @@ extension View { } private struct TappablePadding: ViewModifier { - @State var actualViewSize: CGSize = .zero - let tappableViewSize = UIMetrics.Button.minimumTappableAreaSize + @State private var actualViewSize: CGSize = .zero + private let tappableViewSize = UIMetrics.Button.minimumTappableAreaSize func body(content: Content) -> some View { content @@ -28,6 +28,5 @@ private struct TappablePadding: ViewModifier { height: max(actualViewSize.height, tappableViewSize.height) ) .contentShape(Rectangle()) - .frame(width: actualViewSize.width, height: actualViewSize.height) } } diff --git a/ios/MullvadVPN/Supporting Files/Assets.xcassets/IconReload.imageset/Contents.json b/ios/MullvadVPN/Supporting Files/Assets.xcassets/IconReload.imageset/Contents.json index ff6e72343274..fc394e1bfbbb 100644 --- a/ios/MullvadVPN/Supporting Files/Assets.xcassets/IconReload.imageset/Contents.json +++ b/ios/MullvadVPN/Supporting Files/Assets.xcassets/IconReload.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "IconReload.pdf", + "filename" : "icon-reload.svg", "idiom" : "universal" } ], diff --git a/ios/MullvadVPN/Supporting Files/Assets.xcassets/IconReload.imageset/IconReload.pdf b/ios/MullvadVPN/Supporting Files/Assets.xcassets/IconReload.imageset/IconReload.pdf deleted file mode 100644 index d58fb05aa5f8..000000000000 Binary files a/ios/MullvadVPN/Supporting Files/Assets.xcassets/IconReload.imageset/IconReload.pdf and /dev/null differ diff --git a/ios/MullvadVPN/Supporting Files/Assets.xcassets/IconReload.imageset/icon-reload.svg b/ios/MullvadVPN/Supporting Files/Assets.xcassets/IconReload.imageset/icon-reload.svg new file mode 100644 index 000000000000..6d443ac8b4c0 --- /dev/null +++ b/ios/MullvadVPN/Supporting Files/Assets.xcassets/IconReload.imageset/icon-reload.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/ios/MullvadVPN/UI appearance/UIMetrics.swift b/ios/MullvadVPN/UI appearance/UIMetrics.swift index 1d0f98e8f06c..8eeb56c1598f 100644 --- a/ios/MullvadVPN/UI appearance/UIMetrics.swift +++ b/ios/MullvadVPN/UI appearance/UIMetrics.swift @@ -138,6 +138,11 @@ enum UIMetrics { enum MainButton { static let cornerRadius: CGFloat = 4 } + + enum FeatureIndicators { + static let chipViewHorisontalPadding: CGFloat = 8 + static let chipViewTrailingMargin: CGFloat = 6 + } } extension UIMetrics { diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView.swift deleted file mode 100644 index 03980fb361fd..000000000000 --- a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView.swift +++ /dev/null @@ -1,140 +0,0 @@ -// -// ConnectionView.swift -// MullvadVPN -// -// Created by Jon Petersson on 2024-12-03. -// Copyright © 2024 Mullvad VPN AB. All rights reserved. -// - -import SwiftUI - -typealias ButtonAction = (ConnectionViewViewModel.TunnelControlAction) -> Void - -struct ConnectionView: View { - @StateObject var viewModel: ConnectionViewViewModel - - var action: ButtonAction? - var onContentUpdate: (() -> Void)? - - var body: some View { - VStack(spacing: 22) { - if viewModel.showsActivityIndicator { - CustomProgressView(style: .large) - } - - ZStack { - BlurView(style: .dark) - - VStack(alignment: .leading, spacing: 16) { - ConnectionPanel(viewModel: viewModel) - ButtonPanel(viewModel: viewModel, action: action) - } - .padding(16) - } - .cornerRadius(12) - .padding(16) - } - .onReceive(viewModel.$tunnelState, perform: { _ in - onContentUpdate?() - }) - .onReceive(viewModel.$showsActivityIndicator, perform: { _ in - onContentUpdate?() - }) - } -} - -#Preview { - ConnectionView(viewModel: ConnectionViewViewModel(tunnelState: .disconnected)) { action in - print(action) - } - .background(UIColor.secondaryColor.color) -} - -private struct ConnectionPanel: View { - @StateObject var viewModel: ConnectionViewViewModel - - var body: some View { - VStack(alignment: .leading) { - Text(viewModel.localizedTitleForSecureLabel) - .textCase(.uppercase) - .font(.title3.weight(.semibold)) - .foregroundStyle(viewModel.textColorForSecureLabel.color) - .padding(.bottom, 4) - - if let countryAndCity = viewModel.titleForCountryAndCity, let server = viewModel.titleForServer { - Text(countryAndCity) - .font(.title3.weight(.semibold)) - .foregroundStyle(UIColor.primaryTextColor.color) - Text(server) - .font(.body) - .foregroundStyle(UIColor.primaryTextColor.color.opacity(0.6)) - } - } - .accessibilityLabel(viewModel.localizedAccessibilityLabel) - } -} - -private struct ButtonPanel: View { - @StateObject var viewModel: ConnectionViewViewModel - var action: ButtonAction? - - var body: some View { - VStack(spacing: 16) { - locationButton(with: action) - actionButton(with: action) - } - } - - @ViewBuilder - private func locationButton(with action: ButtonAction?) -> some View { - switch viewModel.tunnelState { - case .connecting, .connected, .reconnecting, .waitingForConnectivity, .negotiatingEphemeralPeer, .error: - SplitMainButton( - text: viewModel.localizedTitleForSelectLocationButton, - image: .iconReload, - style: .default, - disabled: viewModel.disableButtons, - primaryAction: { action?(.selectLocation) }, - secondaryAction: { action?(.reconnect) } - ) - case .disconnecting, .pendingReconnect, .disconnected: - MainButton( - text: viewModel.localizedTitleForSelectLocationButton, - style: .default, - disabled: viewModel.disableButtons, - action: { action?(.selectLocation) } - ) - } - } - - @ViewBuilder - private func actionButton(with action: ButtonAction?) -> some View { - switch viewModel.actionButton { - case .connect: - MainButton( - text: LocalizedStringKey("Connect"), - style: .success, - disabled: viewModel.disableButtons, - action: { action?(.connect) } - ) - case .disconnect: - MainButton( - text: LocalizedStringKey("Disconnect"), - style: .danger, - disabled: viewModel.disableButtons, - action: { action?(.disconnect) } - ) - case .cancel: - MainButton( - text: LocalizedStringKey( - viewModel.tunnelState == .waitingForConnectivity(.noConnection) - ? "Disconnect" - : "Cancel" - ), - style: .danger, - disabled: viewModel.disableButtons, - action: { action?(.cancel) } - ) - } - } -} diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ButtonPanel.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ButtonPanel.swift new file mode 100644 index 000000000000..515912004628 --- /dev/null +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ButtonPanel.swift @@ -0,0 +1,90 @@ +// +// ButtonPanel.swift +// MullvadVPN +// +// Created by Andrew Bulhak on 2025-01-03. +// Copyright © 2025 Mullvad VPN AB. All rights reserved. +// + +import SwiftUI + +extension ConnectionView { + internal struct ButtonPanel: View { + typealias Action = (ConnectionViewViewModel.TunnelAction) -> Void + + @ObservedObject var viewModel: ConnectionViewViewModel + var action: Action? + + var body: some View { + VStack(spacing: 16) { + locationButton(with: action) + .disabled(viewModel.disableButtons) + actionButton(with: action) + .disabled(viewModel.disableButtons) + } + } + + @ViewBuilder + private func locationButton(with action: Action?) -> some View { + switch viewModel.tunnelStatus.state { + case .connecting, .connected, .reconnecting, .waitingForConnectivity, .negotiatingEphemeralPeer, .error: + SplitMainButton( + text: viewModel.localizedTitleForSelectLocationButton, + image: .iconReload, + style: .default, + accessibilityId: .selectLocationButton, + primaryAction: { action?(.selectLocation) }, + secondaryAction: { action?(.reconnect) } + ) + case .disconnecting, .pendingReconnect, .disconnected: + MainButton( + text: viewModel.localizedTitleForSelectLocationButton, + style: .default, + action: { action?(.selectLocation) } + ) + .accessibilityIdentifier(AccessibilityIdentifier.selectLocationButton.asString) + } + } + + @ViewBuilder + private func actionButton(with action: Action?) -> some View { + switch viewModel.actionButton { + case .connect: + MainButton( + text: LocalizedStringKey("Connect"), + style: .success, + action: { action?(.connect) } + ) + .accessibilityIdentifier(AccessibilityIdentifier.connectButton.asString) + case .disconnect: + MainButton( + text: LocalizedStringKey("Disconnect"), + style: .danger, + action: { action?(.disconnect) } + ) + .accessibilityIdentifier(AccessibilityIdentifier.disconnectButton.asString) + case .cancel: + MainButton( + text: LocalizedStringKey( + viewModel.tunnelStatus.state == .waitingForConnectivity(.noConnection) + ? "Disconnect" + : "Cancel" + ), + style: .danger, + action: { action?(.cancel) } + ) + .accessibilityIdentifier( + viewModel.tunnelStatus.state == .waitingForConnectivity(.noConnection) + ? AccessibilityIdentifier.disconnectButton.asString + : AccessibilityIdentifier.cancelButton.asString + ) + } + } + } +} + +#Preview { + ConnectionViewComponentPreview(showIndicators: true, isExpanded: true) { _, vm, _ in + ConnectionView.ButtonPanel(viewModel: vm, action: nil) + } +} diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ChipView/ChipContainerView.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ChipView/ChipContainerView.swift new file mode 100644 index 000000000000..0d417542f35c --- /dev/null +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ChipView/ChipContainerView.swift @@ -0,0 +1,95 @@ +// +// ChipContainerView.swift +// MullvadVPN +// +// Created by Mojgan on 2024-12-05. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import SwiftUI + +struct ChipContainerView: View where ViewModel: ChipViewModelProtocol { + @ObservedObject var viewModel: ViewModel + @Binding var isExpanded: Bool + + @State private var chipContainerHeight: CGFloat = .zero + private let verticalPadding: CGFloat = 6 + + var body: some View { + GeometryReader { geo in + let containerWidth = geo.size.width + + let (chipsToAdd, showMoreButton) = if isExpanded { + (viewModel.chips, false) + } else { + viewModel.chipsToAdd(forContainerWidth: containerWidth) + } + + HStack { + ZStack(alignment: .topLeading) { + createChipViews(chips: chipsToAdd, containerWidth: containerWidth) + } + + Button(LocalizedStringKey("\(viewModel.chips.count - chipsToAdd.count) more...")) { + isExpanded.toggle() + } + .font(.subheadline) + .lineLimit(1) + .foregroundStyle(UIColor.primaryTextColor.color) + .showIf(showMoreButton) + + Spacer() + } + .sizeOfView { chipContainerHeight = $0.height } + } + .frame(height: chipContainerHeight) + .padding(.vertical, -(verticalPadding - 1)) // Remove extra padding from chip views on top and bottom. + } + + private func createChipViews(chips: [ChipModel], containerWidth: CGFloat) -> some View { + var width = CGFloat.zero + var height = CGFloat.zero + + return ForEach(chips) { data in + ChipView(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(.top) { _ in + let result = height + if data.id == chips.last?.id { + height = 0 + } + return result + } + } + } +} + +#Preview("Tap to expand") { + StatefulPreviewWrapper(false) { isExpanded in + ChipContainerView( + viewModel: MockFeatureIndicatorsViewModel(), + isExpanded: isExpanded + ) + .background(UIColor.secondaryColor.color) + } +} diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ChipView/ChipFeature.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ChipView/ChipFeature.swift new file mode 100644 index 000000000000..a661a2cefc23 --- /dev/null +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ChipView/ChipFeature.swift @@ -0,0 +1,127 @@ +// +// ChipFeature.swift +// MullvadVPN +// +// Created by Mojgan on 2024-12-06. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// +import MullvadSettings +import SwiftUI + +// Opting to use NSLocalizedString instead of LocalizedStringKey here in order +// 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 { + var isEnabled: Bool { get } + var name: String { get } +} + +struct DaitaFeature: ChipFeature { + let settings: LatestTunnelSettings + + var isEnabled: Bool { + settings.daita.daitaState.isEnabled + } + + var name: String { + NSLocalizedString( + "FEATURE_INDICATORS_CHIP_DAITA", + tableName: "FeatureIndicatorsChip", + value: "DAITA", + comment: "" + ) + } +} + +struct QuantumResistanceFeature: ChipFeature { + let settings: LatestTunnelSettings + var isEnabled: Bool { + settings.tunnelQuantumResistance.isEnabled + } + + var name: String { + NSLocalizedString( + "FEATURE_INDICATORS_CHIP_QUANTUM_RESISTANCE", + tableName: "FeatureIndicatorsChip", + value: "Quantum resistance", + comment: "" + ) + } +} + +struct MultihopFeature: ChipFeature { + let settings: LatestTunnelSettings + var isEnabled: Bool { + settings.tunnelMultihopState.isEnabled + } + + var name: String { + NSLocalizedString( + "FEATURE_INDICATORS_CHIP_MULTIHOP", + tableName: "FeatureIndicatorsChip", + value: "Multihop", + comment: "" + ) + } +} + +struct ObfuscationFeature: ChipFeature { + let settings: LatestTunnelSettings + + var isEnabled: Bool { + settings.wireGuardObfuscation.state.isEnabled + } + + var name: String { + NSLocalizedString( + "FEATURE_INDICATORS_CHIP_OBFUSCATION", + tableName: "FeatureIndicatorsChip", + value: "Obfuscation", + comment: "" + ) + } +} + +struct DNSFeature: ChipFeature { + let settings: LatestTunnelSettings + + var isEnabled: Bool { + settings.dnsSettings.enableCustomDNS || !settings.dnsSettings.blockingOptions.isEmpty + } + + var name: String { + if !settings.dnsSettings.blockingOptions.isEmpty { + NSLocalizedString( + "FEATURE_INDICATORS_CHIP_CONTENT_BLOCKERS", + tableName: "FeatureIndicatorsChip", + value: "DNS content blockers", + comment: "" + ) + } else { + NSLocalizedString( + "FEATURE_INDICATORS_CHIP_CUSTOM_DNS", + tableName: "FeatureIndicatorsChip", + value: "Custom DNS", + comment: "" + ) + } + } +} + +struct IPOverrideFeature: ChipFeature { + let overrides: [IPOverride] + + var isEnabled: Bool { + !overrides.isEmpty + } + + var name: String { + NSLocalizedString( + "FEATURE_INDICATORS_CHIP_IP_OVERRIDE", + tableName: "FeatureIndicatorsChip", + value: "Server IP Override", + comment: "" + ) + } +} diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ChipView/ChipModel.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ChipView/ChipModel.swift new file mode 100644 index 000000000000..a746897c06d2 --- /dev/null +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ChipView/ChipModel.swift @@ -0,0 +1,15 @@ +// +// FeatureChipModel.swift +// MullvadVPN +// +// Created by Mojgan on 2024-12-05. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import SwiftUI + +struct ChipModel: Identifiable { + let id = UUID() + let name: String +} diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ChipView/ChipView.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ChipView/ChipView.swift new file mode 100644 index 000000000000..57fc7cb042d3 --- /dev/null +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ChipView/ChipView.swift @@ -0,0 +1,40 @@ +// +// FeatureChipView.swift +// MullvadVPN +// +// Created by Mojgan on 2024-12-05. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import SwiftUI + +struct ChipView: View { + let item: ChipModel + 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: 1 + ) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(UIColor.secondaryColor.color) + ) + ) + } +} + +#Preview { + ZStack { + ChipView(item: ChipModel(name: "Example")) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(UIColor.secondaryColor.color) +} diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ChipView/ChipViewModelProtocol.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ChipView/ChipViewModelProtocol.swift new file mode 100644 index 000000000000..3a4c9da337da --- /dev/null +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ChipView/ChipViewModelProtocol.swift @@ -0,0 +1,65 @@ +// +// ChipViewModelProtocol.swift +// MullvadVPN +// +// Created by Mojgan on 2024-12-05. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import SwiftUI + +protocol ChipViewModelProtocol: ObservableObject { + var chips: [ChipModel] { get } +} + +extension ChipViewModelProtocol { + func chipsToAdd(forContainerWidth containerWidth: CGFloat) -> (chips: [ChipModel], isOverflowing: Bool) { + var chipsToAdd = [ChipModel]() + var isOverflowing = false + + let moreTextWidth = String( + format: NSLocalizedString( + "CONNECTION_VIEW_CHIPS_MORE", + tableName: "ConnectionView", + value: "@d more...", + comment: "" + ), arguments: [chips.count] + ) + .width(using: .preferredFont(forTextStyle: .subheadline)) + 4 // Some extra to be safe. + var totalChipsWidth: CGFloat = 0 + + for (index, chip) in chips.enumerated() { + let textWidth = chip.name.width(using: .preferredFont(forTextStyle: .subheadline)) + let chipWidth = textWidth + + UIMetrics.FeatureIndicators.chipViewHorisontalPadding * 2 + + UIMetrics.FeatureIndicators.chipViewTrailingMargin + let isLastChip = index == chips.count - 1 + + totalChipsWidth += chipWidth + + let chipWillFitWithMoreText = (totalChipsWidth + moreTextWidth) <= containerWidth + let chipWillFit = totalChipsWidth <= containerWidth + + guard (chipWillFit && isLastChip) || chipWillFitWithMoreText else { + isOverflowing = true + break + } + + chipsToAdd.append(chip) + } + + return (chipsToAdd, isOverflowing) + } +} + +class MockFeatureIndicatorsViewModel: ChipViewModelProtocol { + @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"), + ] +} diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ConnectionView.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ConnectionView.swift new file mode 100644 index 000000000000..6dfee3abed08 --- /dev/null +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ConnectionView.swift @@ -0,0 +1,89 @@ +// +// ConnectionView.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-12-03. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import SwiftUI + +struct ConnectionView: View { + @ObservedObject var connectionViewModel: ConnectionViewViewModel + @ObservedObject var indicatorsViewModel: FeatureIndicatorsViewModel + + @State private(set) var isExpanded = false + + var action: ButtonPanel.Action? + var onContentUpdate: (() -> Void)? + + var body: some View { + Spacer() + .accessibilityIdentifier(AccessibilityIdentifier.connectionView.asString) + + VStack(spacing: 22) { + CustomProgressView(style: .large) + .showIf(connectionViewModel.showsActivityIndicator) + + ZStack { + BlurView(style: .dark) + + VStack(alignment: .leading, spacing: 0) { + HeaderView(viewModel: connectionViewModel, isExpanded: $isExpanded) + .padding(.bottom, headerViewBottomPadding) + + DetailsContainer( + connectionViewModel: connectionViewModel, + indicatorsViewModel: indicatorsViewModel, + isExpanded: $isExpanded + ) + .showIf(connectionViewModel.showConnectionDetails) + + ButtonPanel(viewModel: connectionViewModel, action: action) + .padding(.top, 16) + } + .padding(16) + } + .cornerRadius(12) + .padding(16) + } + .padding(.bottom, 8) // Some spacing to avoid overlap with the map legal link. + .onChange(of: isExpanded) { _ in + onContentUpdate?() + } + .onReceive(connectionViewModel.combinedState) { _, _ in + // Only update expanded state when connections details should be hidden. + // This will contract the view on eg. disconnect, but leave it as-is on + // eg. connect. + if !connectionViewModel.showConnectionDetails { + isExpanded = false + return + } + + onContentUpdate?() + } + } +} + +extension ConnectionView { + var headerViewBottomPadding: CGFloat { + let hasIndicators = !indicatorsViewModel.chips.isEmpty + let showConnectionDetails = connectionViewModel.showConnectionDetails + + return isExpanded + ? 16 + : hasIndicators && showConnectionDetails ? 16 : 0 + } +} + +#Preview("ConnectionView (Indicators)") { + ConnectionViewComponentPreview(showIndicators: true, isExpanded: true) { indicatorModel, viewModel, _ in + ConnectionView(connectionViewModel: viewModel, indicatorsViewModel: indicatorModel) + } +} + +#Preview("ConnectionView (No indicators)") { + ConnectionViewComponentPreview(showIndicators: false, isExpanded: true) { indicatorModel, viewModel, _ in + ConnectionView(connectionViewModel: viewModel, indicatorsViewModel: indicatorModel) + } +} diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ConnectionViewComponentPreview.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ConnectionViewComponentPreview.swift new file mode 100644 index 000000000000..cc24537f13d7 --- /dev/null +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ConnectionViewComponentPreview.swift @@ -0,0 +1,68 @@ +// +// ConnectionViewComponentPreview.swift +// MullvadVPN +// +// Created by Andrew Bulhak on 2025-01-03. +// Copyright © 2025 Mullvad VPN AB. All rights reserved. +// + +import MullvadMockData +import MullvadSettings +import MullvadTypes +import PacketTunnelCore +import SwiftUI + +struct ConnectionViewComponentPreview: View { + let showIndicators: Bool + + private var tunnelSettings: LatestTunnelSettings { + LatestTunnelSettings( + wireGuardObfuscation: WireGuardObfuscationSettings(state: showIndicators ? .udpOverTcp : .off), + tunnelQuantumResistance: showIndicators ? .on : .off, + tunnelMultihopState: showIndicators ? .on : .off, + daita: DAITASettings(daitaState: showIndicators ? .on : .off) + ) + } + + private let viewModel = ConnectionViewViewModel( + tunnelStatus: TunnelStatus( + observedState: .connected(ObservedConnectionState( + selectedRelays: SelectedRelaysStub.selectedRelays, + relayConstraints: RelayConstraints(entryLocations: .any, exitLocations: .any, port: .any, filter: .any), + networkReachability: .reachable, + connectionAttemptCount: 0, + transportLayer: .udp, + remotePort: 80, + isPostQuantum: true, + isDaitaEnabled: true + )), + state: .connected(SelectedRelaysStub.selectedRelays, isPostQuantum: true, isDaita: true) + ) + ) + + var content: (FeatureIndicatorsViewModel, ConnectionViewViewModel, Binding) -> Content + + @State var isExpanded: Bool + + init( + showIndicators: Bool, + isExpanded: Bool, + content: @escaping (FeatureIndicatorsViewModel, ConnectionViewViewModel, Binding) -> Content + ) { + self.showIndicators = showIndicators + self._isExpanded = State(wrappedValue: isExpanded) + self.content = content + } + + var body: some View { + content( + FeatureIndicatorsViewModel( + tunnelSettings: tunnelSettings, + ipOverrides: [] + ), + viewModel, + $isExpanded + ) + .background(UIColor.secondaryColor.color) + } +} diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionViewViewModel.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ConnectionViewViewModel.swift similarity index 50% rename from ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionViewViewModel.swift rename to ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ConnectionViewViewModel.swift index 29a4748b4100..962eaa0d63fd 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionViewViewModel.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ConnectionViewViewModel.swift @@ -6,16 +6,17 @@ // Copyright © 2024 Mullvad VPN AB. All rights reserved. // +import Combine import SwiftUI class ConnectionViewViewModel: ObservableObject { - enum TunnelControlActionButton { + enum TunnelActionButton { case connect case disconnect case cancel } - enum TunnelControlAction { + enum TunnelAction { case connect case disconnect case cancel @@ -23,42 +24,69 @@ class ConnectionViewViewModel: ObservableObject { case selectLocation } - @Published var tunnelState: TunnelState + @Published var tunnelStatus: TunnelStatus + @Published var outgoingConnectionInfo: OutgoingConnectionInfo? @Published var showsActivityIndicator = false - init(tunnelState: TunnelState) { - self.tunnelState = tunnelState + var combinedState: Publishers.CombineLatest< + Published.Publisher, + Published.Publisher + > { + $tunnelStatus.combineLatest($showsActivityIndicator) + } + + var tunnelIsConnected: Bool { + if case .connected = tunnelStatus.state { + true + } else { + false + } + } + + init(tunnelStatus: TunnelStatus) { + self.tunnelStatus = tunnelStatus } } extension ConnectionViewViewModel { + var showConnectionDetails: Bool { + switch tunnelStatus.state { + case .connecting, .reconnecting, .waitingForConnectivity(.noConnection), .negotiatingEphemeralPeer, + .connected, .pendingReconnect: + true + case .disconnecting, .disconnected, .waitingForConnectivity(.noNetwork), .error: + false + } + } + var textColorForSecureLabel: UIColor { - switch tunnelState { - case .connecting, .reconnecting, .waitingForConnectivity(.noConnection), .negotiatingEphemeralPeer: + switch tunnelStatus.state { + case .connecting, .reconnecting, .waitingForConnectivity(.noConnection), .negotiatingEphemeralPeer, + .pendingReconnect, .disconnecting: .white case .connected: .successColor - case .disconnecting, .disconnected, .pendingReconnect, .waitingForConnectivity(.noNetwork), .error: + case .disconnected, .waitingForConnectivity(.noNetwork), .error: .dangerColor } } var disableButtons: Bool { - if case .waitingForConnectivity(.noNetwork) = tunnelState { - return true + if case .waitingForConnectivity(.noNetwork) = tunnelStatus.state { + true + } else { + false } - - return false } var localizedTitleForSecureLabel: LocalizedStringKey { - switch tunnelState { + switch tunnelStatus.state { case .connecting, .reconnecting, .negotiatingEphemeralPeer: - LocalizedStringKey("Connecting") + LocalizedStringKey("Connecting...") case .connected: LocalizedStringKey("Connected") case .disconnecting(.nothing): - LocalizedStringKey("Disconnecting") + LocalizedStringKey("Disconnecting...") case .disconnecting(.reconnect), .pendingReconnect: LocalizedStringKey("Reconnecting") case .disconnected: @@ -70,17 +98,19 @@ extension ConnectionViewViewModel { } } - var localizedTitleForSelectLocationButton: LocalizedStringKey { - switch tunnelState { - case .disconnecting, .pendingReconnect, .disconnected: - LocalizedStringKey("Select location") - case .connecting, .connected, .reconnecting, .waitingForConnectivity, .negotiatingEphemeralPeer, .error: - LocalizedStringKey("Switch location") + var accessibilityIdForSecureLabel: AccessibilityIdentifier { + switch tunnelStatus.state { + case .connected: + .connectionStatusConnectedLabel + case .connecting: + .connectionStatusConnectingLabel + default: + .connectionStatusNotConnectedLabel } } - var localizedAccessibilityLabel: LocalizedStringKey { - switch tunnelState { + var localizedAccessibilityLabelForSecureLabel: LocalizedStringKey { + switch tunnelStatus.state { case .disconnected, .waitingForConnectivity, .disconnecting, .pendingReconnect, .error: localizedTitleForSecureLabel case let .connected(tunnelInfo, _, _): @@ -98,8 +128,18 @@ extension ConnectionViewViewModel { } } - var actionButton: TunnelControlActionButton { - switch tunnelState { + var localizedTitleForSelectLocationButton: LocalizedStringKey { + switch tunnelStatus.state { + case .disconnecting, .pendingReconnect, .disconnected, .waitingForConnectivity(.noNetwork): + LocalizedStringKey("Select location") + case .connecting, .connected, .reconnecting, .waitingForConnectivity(.noConnection), + .negotiatingEphemeralPeer, .error: + LocalizedStringKey("Switch location") + } + } + + var actionButton: TunnelActionButton { + switch tunnelStatus.state { case .disconnected, .disconnecting(.nothing), .waitingForConnectivity(.noNetwork): .connect case .connecting, .pendingReconnect, .disconnecting(.reconnect), .waitingForConnectivity(.noConnection), @@ -111,7 +151,7 @@ extension ConnectionViewViewModel { } var titleForCountryAndCity: LocalizedStringKey? { - guard tunnelState.isSecured, let tunnelRelays = tunnelState.relays else { + guard let tunnelRelays = tunnelStatus.state.relays else { return nil } @@ -119,7 +159,7 @@ extension ConnectionViewViewModel { } var titleForServer: LocalizedStringKey? { - guard tunnelState.isSecured, let tunnelRelays = tunnelState.relays else { + guard let tunnelRelays = tunnelStatus.state.relays else { return nil } @@ -132,4 +172,50 @@ extension ConnectionViewViewModel { LocalizedStringKey("\(exitName)") } } + + var inAddress: String? { + guard let tunnelRelays = tunnelStatus.state.relays else { + return nil + } + + let observedTunnelState = tunnelStatus.observedState + + var portAndTransport = "" + if let inPort = observedTunnelState.connectionState?.remotePort { + let protocolLayer = observedTunnelState.connectionState?.transportLayer == .tcp ? "TCP" : "UDP" + portAndTransport = ":\(inPort) \(protocolLayer)" + } + + guard + let address = tunnelRelays.entry?.endpoint.ipv4Relay.ip + ?? tunnelStatus.state.relays?.exit.endpoint.ipv4Relay.ip + else { + return nil + } + + return "\(address)\(portAndTransport)" + } + + var outAddressIpv4: String? { + guard + let outgoingConnectionInfo, + let address = outgoingConnectionInfo.ipv4.exitIP ? outgoingConnectionInfo.ipv4.ip : nil + else { + return nil + } + + return "\(address)" + } + + var outAddressIpv6: String? { + guard + let outgoingConnectionInfo, + let ipv6 = outgoingConnectionInfo.ipv6, + let address = ipv6.exitIP ? ipv6.ip : nil + else { + return nil + } + + return "\(address)" + } } diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/DetailsContainer.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/DetailsContainer.swift new file mode 100644 index 000000000000..6b2bb00399a1 --- /dev/null +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/DetailsContainer.swift @@ -0,0 +1,57 @@ +// +// DetailsContainer.swift +// MullvadVPN +// +// Created by Andrew Bulhak on 2025-01-03. +// Copyright © 2025 Mullvad VPN AB. All rights reserved. +// + +import SwiftUI + +extension ConnectionView { + internal struct DetailsContainer: View { + @ObservedObject var connectionViewModel: ConnectionViewViewModel + @ObservedObject var indicatorsViewModel: FeatureIndicatorsViewModel + @Binding var isExpanded: Bool + + @State private var scrollViewHeight: CGFloat = 0 + + var body: some View { + VStack(spacing: 16) { + Divider() + .background(UIColor.secondaryTextColor.color) + .showIf(isExpanded) + + ScrollView { + VStack(spacing: 16) { + FeatureIndicatorsView( + viewModel: indicatorsViewModel, + isExpanded: $isExpanded + ) + .showIf(!indicatorsViewModel.chips.isEmpty) + + DetailsView(viewModel: connectionViewModel) + .showIf(isExpanded) + } + .sizeOfView { scrollViewHeight = $0.height } + } + .frame(maxHeight: scrollViewHeight) + .onTapGesture { + // If this callback is not set the child views will not reliably register tap events. + // This is a bug in iOS 16 and 17, but seemingly fixed in 18. Once we set the lowest + // supported version to iOS 18 we can probably remove it. + } + } + } + } +} + +#Preview { + ConnectionViewComponentPreview(showIndicators: true, isExpanded: true) { indicatorModel, viewModel, isExpanded in + ConnectionView.DetailsContainer( + connectionViewModel: viewModel, + indicatorsViewModel: indicatorModel, + isExpanded: isExpanded + ) + } +} diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/DetailsView.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/DetailsView.swift new file mode 100644 index 000000000000..ff07dc94b582 --- /dev/null +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/DetailsView.swift @@ -0,0 +1,64 @@ +// +// DetailsView.swift +// MullvadVPN +// +// Created by Andrew Bulhak on 2025-01-03. +// Copyright © 2025 Mullvad VPN AB. All rights reserved. +// + +import SwiftUI + +extension ConnectionView { + internal struct DetailsView: View { + @ObservedObject var viewModel: ConnectionViewViewModel + @State private var columnWidth: CGFloat = 0 + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(LocalizedStringKey("Connection details")) + .font(.footnote.weight(.semibold)) + .foregroundStyle(UIColor.primaryTextColor.color.opacity(0.6)) + Spacer() + } + + VStack(alignment: .leading, spacing: 0) { + if let inAddress = viewModel.inAddress { + connectionDetailRow(title: LocalizedStringKey("In"), value: inAddress) + .accessibilityIdentifier(AccessibilityIdentifier.connectionPanelInAddressRow.asString) + } + if viewModel.tunnelIsConnected { + if let outAddressIpv4 = viewModel.outAddressIpv4 { + connectionDetailRow(title: LocalizedStringKey("Out IPv4"), value: outAddressIpv4) + .accessibilityIdentifier(AccessibilityIdentifier.connectionPanelOutAddressRow.asString) + } + if let outAddressIpv6 = viewModel.outAddressIpv6 { + connectionDetailRow(title: LocalizedStringKey("Out IPv6"), value: outAddressIpv6) + .accessibilityIdentifier(AccessibilityIdentifier.connectionPanelOutAddressRow.asString) + } + } + } + } + } + + @ViewBuilder + private func connectionDetailRow(title: LocalizedStringKey, value: String) -> some View { + HStack(alignment: .top, spacing: 8) { + Text(title) + .font(.subheadline) + .foregroundStyle(UIColor.primaryTextColor.color.opacity(0.6)) + .frame(minWidth: columnWidth, alignment: .leading) + .sizeOfView { columnWidth = max(columnWidth, $0.width) } + Text(value) + .font(.subheadline) + .foregroundStyle(UIColor.primaryTextColor.color) + } + } + } +} + +#Preview { + ConnectionViewComponentPreview(showIndicators: true, isExpanded: true) { _, vm, _ in + ConnectionView.DetailsView(viewModel: vm) + } +} diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/HeaderView.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/HeaderView.swift new file mode 100644 index 000000000000..fa1112a80e1c --- /dev/null +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/HeaderView.swift @@ -0,0 +1,63 @@ +// +// HeaderView.swift +// MullvadVPN +// +// Created by Andrew Bulhak on 2025-01-03. +// Copyright © 2025 Mullvad VPN AB. All rights reserved. +// + +import SwiftUI + +extension ConnectionView { + internal struct HeaderView: View { + @ObservedObject var viewModel: ConnectionViewViewModel + @Binding var isExpanded: Bool + + var body: some View { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 0) { + Text(viewModel.localizedTitleForSecureLabel) + .textCase(.uppercase) + .font(.title3.weight(.semibold)) + .foregroundStyle(viewModel.textColorForSecureLabel.color) + .accessibilityIdentifier(viewModel.accessibilityIdForSecureLabel.asString) + + if let countryAndCity = viewModel.titleForCountryAndCity { + Text(countryAndCity) + .font(.title3.weight(.semibold)) + .foregroundStyle(UIColor.primaryTextColor.color) + .padding(.top, 4) + } + + if let server = viewModel.titleForServer { + Text(server) + .font(.body) + .foregroundStyle(UIColor.primaryTextColor.color.opacity(0.6)) + .padding(.top, 2) + } + } + .accessibilityLabel(viewModel.localizedAccessibilityLabelForSecureLabel) + + Group { + Spacer() + Image(.iconChevron) + .renderingMode(.template) + .rotationEffect(isExpanded ? .degrees(-90) : .degrees(90)) + .foregroundStyle(.white) + .accessibilityIdentifier(AccessibilityIdentifier.relayStatusCollapseButton.asString) + } + .showIf(viewModel.showConnectionDetails) + } + .contentShape(Rectangle()) + .onTapGesture { + isExpanded.toggle() + } + } + } +} + +#Preview { + ConnectionViewComponentPreview(showIndicators: true, isExpanded: true) { _, vm, isExpanded in + ConnectionView.HeaderView(viewModel: vm, isExpanded: isExpanded) + } +} diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/FI_TunnelViewController.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/FI_TunnelViewController.swift index b70c3a9ffa37..bb754a0c25fa 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/FI_TunnelViewController.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/FI_TunnelViewController.swift @@ -6,8 +6,10 @@ // Copyright © 2024 Mullvad VPN AB. All rights reserved. // +import Combine import MapKit import MullvadLogging +import MullvadSettings import MullvadTypes import SwiftUI @@ -17,7 +19,8 @@ class FI_TunnelViewController: UIViewController, RootContainment { private let logger = Logger(label: "TunnelViewController") private let interactor: TunnelViewControllerInteractor private var tunnelState: TunnelState = .disconnected - private var viewModel = ConnectionViewViewModel(tunnelState: .disconnected) + private var connectionViewViewModel: ConnectionViewViewModel + private var indicatorsViewViewModel: FeatureIndicatorsViewModel private var connectionView: ConnectionView private var connectionController: UIHostingController? @@ -48,7 +51,18 @@ class FI_TunnelViewController: UIViewController, RootContainment { init(interactor: TunnelViewControllerInteractor) { self.interactor = interactor - connectionView = ConnectionView(viewModel: self.viewModel) + + tunnelState = interactor.tunnelStatus.state + connectionViewViewModel = ConnectionViewViewModel(tunnelStatus: interactor.tunnelStatus) + indicatorsViewViewModel = FeatureIndicatorsViewModel( + tunnelSettings: interactor.tunnelSettings, + ipOverrides: interactor.ipOverrides + ) + + connectionView = ConnectionView( + connectionViewModel: self.connectionViewViewModel, + indicatorsViewModel: self.indicatorsViewViewModel + ) super.init(nibName: nil, bundle: nil) @@ -72,11 +86,23 @@ class FI_TunnelViewController: UIViewController, RootContainment { } interactor.didUpdateTunnelStatus = { [weak self] tunnelStatus in + self?.connectionViewViewModel.tunnelStatus = tunnelStatus self?.setTunnelState(tunnelStatus.state, animated: true) - self?.viewModel.tunnelState = tunnelStatus.state self?.view.setNeedsLayout() } + interactor.didGetOutgoingAddress = { [weak self] connectionInfo in + self?.connectionViewViewModel.outgoingConnectionInfo = connectionInfo + } + + interactor.didUpdateTunnelSettings = { [weak self] tunnelSettings in + self?.indicatorsViewViewModel.tunnelSettings = tunnelSettings + } + + interactor.didUpdateIpOverrides = { [weak self] overrides in + self?.indicatorsViewViewModel.ipOverrides = overrides + } + connectionView.action = { [weak self] action in switch action { case .connect: @@ -102,10 +128,6 @@ class FI_TunnelViewController: UIViewController, RootContainment { addMapController() addContentView() - - tunnelState = interactor.tunnelStatus.state - viewModel.tunnelState = tunnelState - updateMap(animated: false) } @@ -125,6 +147,7 @@ class FI_TunnelViewController: UIViewController, RootContainment { private func setTunnelState(_ tunnelState: TunnelState, animated: Bool) { self.tunnelState = tunnelState + setNeedsHeaderBarStyleAppearanceUpdate() guard isViewLoaded else { return } @@ -137,17 +160,17 @@ class FI_TunnelViewController: UIViewController, RootContainment { case let .connecting(tunnelRelays, _, _): mapViewController.removeLocationMarker() mapViewController.setCenter(tunnelRelays?.exit.location.geoCoordinate, animated: animated) - viewModel.showsActivityIndicator = true + connectionViewViewModel.showsActivityIndicator = true case let .reconnecting(tunnelRelays, _, _), let .negotiatingEphemeralPeer(tunnelRelays, _, _, _): mapViewController.removeLocationMarker() mapViewController.setCenter(tunnelRelays.exit.location.geoCoordinate, animated: animated) - viewModel.showsActivityIndicator = true + connectionViewViewModel.showsActivityIndicator = true case let .connected(tunnelRelays, _, _): let center = tunnelRelays.exit.location.geoCoordinate mapViewController.setCenter(center, animated: animated) { - self.viewModel.showsActivityIndicator = false + self.connectionViewViewModel.showsActivityIndicator = false // Connection can change during animation, so make sure we're still connected before adding marker. if case .connected = self.tunnelState { @@ -157,16 +180,16 @@ class FI_TunnelViewController: UIViewController, RootContainment { case .pendingReconnect: mapViewController.removeLocationMarker() - viewModel.showsActivityIndicator = true + connectionViewViewModel.showsActivityIndicator = true case .waitingForConnectivity, .error: mapViewController.removeLocationMarker() - viewModel.showsActivityIndicator = false + connectionViewViewModel.showsActivityIndicator = false case .disconnected, .disconnecting: mapViewController.removeLocationMarker() mapViewController.setCenter(nil, animated: animated) - viewModel.showsActivityIndicator = false + connectionViewViewModel.showsActivityIndicator = false } } @@ -192,7 +215,7 @@ class FI_TunnelViewController: UIViewController, RootContainment { connectionController.didMove(toParent: self) view.addConstrainedSubviews([connectionViewProxy]) { - connectionViewProxy.pinEdgesToSuperview(.all().excluding(.top)) + connectionViewProxy.pinEdgesToSuperview(.all()) } } } diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/FeatureIndicatorsView.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/FeatureIndicatorsView.swift new file mode 100644 index 000000000000..70e49a9c0493 --- /dev/null +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/FeatureIndicatorsView.swift @@ -0,0 +1,33 @@ +// +// FeaturesIndicatorsView.swift +// MullvadVPN +// +// Created by Mojgan on 2024-12-06. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import SwiftUI + +struct FeatureIndicatorsView: View where ViewModel: ChipViewModelProtocol { + @ObservedObject var viewModel: ViewModel + @Binding var isExpanded: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(LocalizedStringKey("Active features")) + .font(.footnote.weight(.semibold)) + .foregroundStyle(UIColor.primaryTextColor.color.opacity(0.6)) + .showIf(isExpanded) + + ChipContainerView(viewModel: viewModel, isExpanded: $isExpanded) + } + } +} + +#Preview { + FeatureIndicatorsView( + viewModel: MockFeatureIndicatorsViewModel(), + isExpanded: .constant(true) + ) + .background(UIColor.secondaryColor.color) +} diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/FeatureIndicatorsViewModel.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/FeatureIndicatorsViewModel.swift new file mode 100644 index 000000000000..97eac59ca8f3 --- /dev/null +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/FeatureIndicatorsViewModel.swift @@ -0,0 +1,35 @@ +// +// FeatureIndicatorsViewModel.swift +// MullvadVPN +// +// Created by Mojgan on 2024-12-05. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import MullvadSettings +import SwiftUI + +class FeatureIndicatorsViewModel: ChipViewModelProtocol { + @Published var tunnelSettings: LatestTunnelSettings + @Published var ipOverrides: [IPOverride] + + init(tunnelSettings: LatestTunnelSettings, ipOverrides: [IPOverride]) { + self.tunnelSettings = tunnelSettings + self.ipOverrides = ipOverrides + } + + var chips: [ChipModel] { + let features: [ChipFeature] = [ + DaitaFeature(settings: tunnelSettings), + QuantumResistanceFeature(settings: tunnelSettings), + MultihopFeature(settings: tunnelSettings), + ObfuscationFeature(settings: tunnelSettings), + DNSFeature(settings: tunnelSettings), + IPOverrideFeature(overrides: ipOverrides), + ] + + return features + .filter { $0.isEnabled } + .map { ChipModel(name: $0.name) } + } +} diff --git a/ios/MullvadVPN/View controllers/Tunnel/TunnelControlView.swift b/ios/MullvadVPN/View controllers/Tunnel/TunnelControlView.swift index 88c933493b00..cff522297669 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/TunnelControlView.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/TunnelControlView.swift @@ -60,7 +60,7 @@ final class TunnelControlView: UIView { private let connectButton: AppButton = { let button = AppButton(style: .success) - button.setAccessibilityIdentifier(.secureConnectionButton) + button.setAccessibilityIdentifier(.connectButton) button.translatesAutoresizingMaskIntoConstraints = false return button }() @@ -115,7 +115,7 @@ final class TunnelControlView: UIView { backgroundColor = .clear directionalLayoutMargins = UIMetrics.contentLayoutMargins accessibilityContainerType = .semanticGroup - setAccessibilityIdentifier(.tunnelControlView) + setAccessibilityIdentifier(.connectionView) addSubviews() addButtonHandlers() diff --git a/ios/MullvadVPN/View controllers/Tunnel/TunnelViewController.swift b/ios/MullvadVPN/View controllers/Tunnel/TunnelViewController.swift index 7cf879f3bf70..78bd6c27b0e9 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/TunnelViewController.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/TunnelViewController.swift @@ -65,7 +65,7 @@ class TunnelViewController: UIViewController, RootContainment { self?.updateViewModel(tunnelStatus: tunnelStatus) } - interactor.didGetOutGoingAddress = { [weak self] outgoingConnectionInfo in + interactor.didGetOutgoingAddress = { [weak self] outgoingConnectionInfo in self?.updateViewModel(outgoingConnectionInfo: outgoingConnectionInfo) } diff --git a/ios/MullvadVPN/View controllers/Tunnel/TunnelViewControllerInteractor.swift b/ios/MullvadVPN/View controllers/Tunnel/TunnelViewControllerInteractor.swift index 47b75fd7d5ef..e072ec283e3b 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/TunnelViewControllerInteractor.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/TunnelViewControllerInteractor.swift @@ -6,7 +6,7 @@ // Copyright © 2022 Mullvad VPN AB. All rights reserved. // -import Foundation +import Combine import MullvadSettings import MullvadTypes @@ -15,10 +15,14 @@ final class TunnelViewControllerInteractor { private let outgoingConnectionService: OutgoingConnectionServiceHandling private var tunnelObserver: TunnelObserver? private var outgoingConnectionTask: Task? + private var ipOverrideRepository: IPOverrideRepositoryProtocol + private var cancellables: Set = [] var didUpdateTunnelStatus: ((TunnelStatus) -> Void)? var didUpdateDeviceState: ((_ deviceState: DeviceState, _ previousDeviceState: DeviceState) -> Void)? - var didGetOutGoingAddress: (@MainActor (OutgoingConnectionInfo) -> Void)? + var didUpdateTunnelSettings: ((LatestTunnelSettings) -> Void)? + var didUpdateIpOverrides: (([IPOverride]) -> Void)? + var didGetOutgoingAddress: (@MainActor (OutgoingConnectionInfo) -> Void)? var tunnelStatus: TunnelStatus { tunnelManager.tunnelStatus @@ -28,16 +32,26 @@ final class TunnelViewControllerInteractor { tunnelManager.deviceState } + var tunnelSettings: LatestTunnelSettings { + tunnelManager.settings + } + + var ipOverrides: [IPOverride] { + ipOverrideRepository.fetchAll() + } + deinit { outgoingConnectionTask?.cancel() } init( tunnelManager: TunnelManager, - outgoingConnectionService: OutgoingConnectionServiceHandling + outgoingConnectionService: OutgoingConnectionServiceHandling, + ipOverrideRepository: IPOverrideRepositoryProtocol ) { self.tunnelManager = tunnelManager self.outgoingConnectionService = outgoingConnectionService + self.ipOverrideRepository = ipOverrideRepository let tunnelObserver = TunnelBlockObserver( didUpdateTunnelStatus: { [weak self] _, tunnelStatus in @@ -50,18 +64,27 @@ final class TunnelViewControllerInteractor { .getOutgoingConnectionInfo() else { return } - await self?.didGetOutGoingAddress?(outgoingConnectionInfo) + await self?.didGetOutgoingAddress?(outgoingConnectionInfo) } } }, didUpdateDeviceState: { [weak self] _, deviceState, previousDeviceState in self?.didUpdateDeviceState?(deviceState, previousDeviceState) + }, + didUpdateTunnelSettings: { [weak self] _, tunnelSettings in + self?.didUpdateTunnelSettings?(tunnelSettings) } ) tunnelManager.addObserver(tunnelObserver) self.tunnelObserver = tunnelObserver + + ipOverrideRepository.overridesPublisher + .sink { [weak self] overrides in + self?.didUpdateIpOverrides?(overrides) + } + .store(in: &cancellables) } func startTunnel() { diff --git a/ios/MullvadVPN/Views/MainButton.swift b/ios/MullvadVPN/Views/MainButton.swift index 679b34a2cd9d..a4240c433db6 100644 --- a/ios/MullvadVPN/Views/MainButton.swift +++ b/ios/MullvadVPN/Views/MainButton.swift @@ -11,7 +11,6 @@ import SwiftUI struct MainButton: View { var text: LocalizedStringKey var style: MainButtonStyle.Style - var disabled = false var action: () -> Void @@ -23,7 +22,7 @@ struct MainButton: View { Spacer() } }) - .buttonStyle(MainButtonStyle(style, disabled: disabled)) + .buttonStyle(MainButtonStyle(style)) .cornerRadius(UIMetrics.MainButton.cornerRadius) } } diff --git a/ios/MullvadVPN/Views/MainButtonStyle.swift b/ios/MullvadVPN/Views/MainButtonStyle.swift index f638c87ac2b5..e13758a15537 100644 --- a/ios/MullvadVPN/Views/MainButtonStyle.swift +++ b/ios/MullvadVPN/Views/MainButtonStyle.swift @@ -10,28 +10,26 @@ import SwiftUI struct MainButtonStyle: ButtonStyle { var style: Style - @State var disabled: Bool + @Environment(\.isEnabled) private var isEnabled: Bool - init(_ style: Style, disabled: Bool = false) { + init(_ style: Style) { self.style = style - self.disabled = disabled } func makeBody(configuration: Configuration) -> some View { - configuration.label - .padding(.horizontal, 8) + return configuration.label .frame(height: 44) .foregroundColor( - configuration.isPressed - ? UIColor.secondaryTextColor.color - : disabled - ? UIColor.primaryTextColor.withAlphaComponent(0.2).color - : UIColor.primaryTextColor.color + isEnabled + ? UIColor.primaryTextColor.color + : UIColor.primaryTextColor.withAlphaComponent(0.2).color ) .background( - disabled - ? style.color.darkened(by: 0.6) - : style.color + isEnabled + ? configuration.isPressed + ? style.pressedColor + : style.color + : style.disabledColor ) .font(.body.weight(.semibold)) } @@ -46,12 +44,20 @@ extension MainButtonStyle { var color: Color { switch self { case .default: - Color(UIColor.primaryColor) + UIColor.primaryColor.color case .danger: - Color(UIColor.dangerColor) + UIColor.dangerColor.color case .success: - Color(UIColor.successColor) + UIColor.successColor.color } } + + var pressedColor: Color { + color.darkened(by: 0.4)! + } + + var disabledColor: Color { + color.darkened(by: 0.6)! + } } } diff --git a/ios/MullvadVPN/Views/SplitMainButton.swift b/ios/MullvadVPN/Views/SplitMainButton.swift index 11336f424ba2..72a879893547 100644 --- a/ios/MullvadVPN/Views/SplitMainButton.swift +++ b/ios/MullvadVPN/Views/SplitMainButton.swift @@ -12,13 +12,13 @@ struct SplitMainButton: View { var text: LocalizedStringKey var image: ImageResource var style: MainButtonStyle.Style - var disabled = false + var accessibilityId: AccessibilityIdentifier? + + @State private var secondaryButtonWidth: CGFloat = 0 var primaryAction: () -> Void var secondaryAction: () -> Void - @State private var width: CGFloat = 0 - var body: some View { HStack(spacing: 1) { Button(action: primaryAction, label: { @@ -27,18 +27,23 @@ struct SplitMainButton: View { Text(text) Spacer() } - .padding(.trailing, -width) + .padding(.trailing, -secondaryButtonWidth) }) + .ifLet(accessibilityId) { view, value in + view.accessibilityIdentifier(value.asString) + } + Button(action: secondaryAction, label: { Image(image) .resizable() .scaledToFit() - .padding(4) + .frame(width: 24, height: 24) + .padding(10) }) .aspectRatio(1, contentMode: .fit) - .sizeOfView { width = $0.width } + .sizeOfView { secondaryButtonWidth = $0.width } } - .buttonStyle(MainButtonStyle(style, disabled: disabled)) + .buttonStyle(MainButtonStyle(style)) .cornerRadius(UIMetrics.MainButton.cornerRadius) } } diff --git a/ios/MullvadVPNTests/MullvadSettings/IPOverrideRepositoryStub.swift b/ios/MullvadVPNTests/MullvadSettings/IPOverrideRepositoryStub.swift index 633bc44bdb5f..c27c5cff5837 100644 --- a/ios/MullvadVPNTests/MullvadSettings/IPOverrideRepositoryStub.swift +++ b/ios/MullvadVPNTests/MullvadSettings/IPOverrideRepositoryStub.swift @@ -6,9 +6,15 @@ // Copyright © 2024 Mullvad VPN AB. All rights reserved. // +import Combine import MullvadSettings struct IPOverrideRepositoryStub: IPOverrideRepositoryProtocol { + let passthroughSubject: CurrentValueSubject<[IPOverride], Never> = CurrentValueSubject([]) + var overridesPublisher: AnyPublisher<[IPOverride], Never> { + passthroughSubject.eraseToAnyPublisher() + } + let overrides: [IPOverride] init(overrides: [IPOverride] = []) { diff --git a/ios/MullvadVPNUITests/Pages/TunnelControlPage.swift b/ios/MullvadVPNUITests/Pages/TunnelControlPage.swift index 6929ee1f5ac5..da47a2c33404 100644 --- a/ios/MullvadVPNUITests/Pages/TunnelControlPage.swift +++ b/ios/MullvadVPNUITests/Pages/TunnelControlPage.swift @@ -74,7 +74,7 @@ class TunnelControlPage: Page { @discardableResult override init(_ app: XCUIApplication) { super.init(app) - self.pageElement = app.otherElements[.tunnelControlView] + self.pageElement = app.otherElements[.connectionView] waitForPageToBeShown() } @@ -84,7 +84,7 @@ class TunnelControlPage: Page { } @discardableResult func tapSecureConnectionButton() -> Self { - app.buttons[AccessibilityIdentifier.secureConnectionButton].tap() + app.buttons[AccessibilityIdentifier.connectButton].tap() return self } diff --git a/ios/PacketTunnelCore/Actor/PacketTunnelActor.swift b/ios/PacketTunnelCore/Actor/PacketTunnelActor.swift index 469bf0fa20d9..9fbc650d7027 100644 --- a/ios/PacketTunnelCore/Actor/PacketTunnelActor.swift +++ b/ios/PacketTunnelCore/Actor/PacketTunnelActor.swift @@ -99,6 +99,7 @@ public actor PacketTunnelActor { } } + // swiftlint:disable:next function_body_length func executeEffect(_ effect: Effect) async { switch effect { case .startDefaultPathObserver: @@ -136,7 +137,6 @@ public actor PacketTunnelActor { state = .disconnected case let .configureForErrorState(reason): await setErrorStateInternal(with: reason) - case let .cacheActiveKey(lastKeyRotation): cacheActiveKey(lastKeyRotation: lastKeyRotation) case let .reconfigureForEphemeralPeer(configuration, configurationSemaphore): diff --git a/ios/convert-assets.rb b/ios/convert-assets.rb index 5419e6282d9e..637068cfe668 100755 --- a/ios/convert-assets.rb +++ b/ios/convert-assets.rb @@ -32,7 +32,6 @@ "icon-extLink.svg", "icon-fail.svg", "icon-info.svg", - "icon-reload.svg", "icon-settings.svg", "icon-spinner.svg", "icon-success.svg",