diff options
| author | Jon Petersson <jon.petersson@mullvad.net> | 2025-10-21 10:38:25 +0200 |
|---|---|---|
| committer | Jon Petersson <jon.petersson@mullvad.net> | 2025-10-21 10:38:25 +0200 |
| commit | 073743db9ccd1b28a8ebc71a0541fa63f1f092f2 (patch) | |
| tree | b6b251112475ea5a6ab693dd4daacc080232acaf | |
| parent | e9da35c8462a3b102a451c32968747186ffaec1f (diff) | |
| parent | 1b6c082c3f0db31a2629e60b45703c34eb831a62 (diff) | |
| download | mullvadvpn-073743db9ccd1b28a8ebc71a0541fa63f1f092f2.tar.xz mullvadvpn-073743db9ccd1b28a8ebc71a0541fa63f1f092f2.zip | |
Merge branch 'store-recents-in-the-settings-ios-1337'
7 files changed, 241 insertions, 10 deletions
diff --git a/ios/MullvadSettings/RecentConnections.swift b/ios/MullvadSettings/RecentConnections.swift new file mode 100644 index 0000000000..d4a83c348b --- /dev/null +++ b/ios/MullvadSettings/RecentConnections.swift @@ -0,0 +1,14 @@ +// +// RecentConnections.swift +// MullvadVPN +// +// Created by Mojgan on 2025-10-15. +// Copyright © 2025 Mullvad VPN AB. All rights reserved. +// +import MullvadTypes + +public struct RecentConnections: Codable, Sendable, Equatable { + let isEnabled: Bool + let entryLocations: [UserSelectedRelays] + let exitLocations: [UserSelectedRelays] +} diff --git a/ios/MullvadSettings/RecentConnectionsRepository.swift b/ios/MullvadSettings/RecentConnectionsRepository.swift new file mode 100644 index 0000000000..b655fd6156 --- /dev/null +++ b/ios/MullvadSettings/RecentConnectionsRepository.swift @@ -0,0 +1,79 @@ +// +// RecentConnectionsRepository.swift +// MullvadVPN +// +// Created by Mojgan on 2025-10-15. +// Copyright © 2025 Mullvad VPN AB. All rights reserved. +// +import MullvadTypes + +public enum RecentConnectionsRepositoryError: LocalizedError, Hashable { + case recentsDisabled + + public var errorDescription: String? { + switch self { + case .recentsDisabled: + "To add the location to the recents, first enable it in the settings." + } + } +} + +final class RecentConnectionsRepository: RecentConnectionsRepositoryProtocol { + private let store: SettingsStore + private let maxLimit: UInt + + private let settingsParser: SettingsParser = { + SettingsParser(decoder: JSONDecoder(), encoder: JSONEncoder()) + }() + + init(store: SettingsStore, maxLimit: UInt = 50) { + self.store = store + self.maxLimit = maxLimit + } + + func setRecentsEnabled(_ isEnabled: Bool) throws { + // Clear all recents whenever the recents feature status changes. + try write(RecentConnections(isEnabled: isEnabled, entryLocations: [], exitLocations: [])) + } + + func add(_ location: UserSelectedRelays, as type: RecentLocationType) throws { + let current = try read() + guard current.isEnabled else { throw RecentConnectionsRepositoryError.recentsDisabled } + var currentList = current[keyPath: keyPath(for: type)] + if let idx = currentList.firstIndex(of: location) { currentList.remove(at: idx) } + currentList.insert(location, at: 0) + currentList = Array(currentList.prefix(Int(maxLimit))) + + let new = + (type == .entry) + ? RecentConnections( + isEnabled: current.isEnabled, entryLocations: currentList, exitLocations: current.exitLocations) + : RecentConnections( + isEnabled: current.isEnabled, entryLocations: current.entryLocations, exitLocations: currentList) + + try write(new) + } + + func all() throws -> RecentConnections { + try read() + } +} + +private extension RecentConnectionsRepository { + private func keyPath(for type: RecentLocationType) -> KeyPath<RecentConnections, [UserSelectedRelays]> { + switch type { + case .entry: return \.entryLocations + case .exit: return \.exitLocations + } + } + + private func read() throws -> RecentConnections { + let data = try store.read(key: .recentConnections) + return try settingsParser.parseUnversionedPayload(as: RecentConnections.self, from: data) + } + + private func write(_ value: RecentConnections) throws { + let data = try settingsParser.produceUnversionedPayload(value) + try store.write(data, for: .recentConnections) + } +} diff --git a/ios/MullvadSettings/RecentConnectionsRepositoryProtocol.swift b/ios/MullvadSettings/RecentConnectionsRepositoryProtocol.swift new file mode 100644 index 0000000000..a7521e6f56 --- /dev/null +++ b/ios/MullvadSettings/RecentConnectionsRepositoryProtocol.swift @@ -0,0 +1,17 @@ +// +// RecentConnectionsRepositoryProtocol.swift +// MullvadVPN +// +// Created by Mojgan on 2025-10-15. +// Copyright © 2025 Mullvad VPN AB. All rights reserved. +// +import MullvadTypes + +public enum RecentLocationType: CaseIterable { + case entry, exit +} +public protocol RecentConnectionsRepositoryProtocol { + func setRecentsEnabled(_ isEnabled: Bool) throws + func add(_ location: UserSelectedRelays, as: RecentLocationType) throws + func all() throws -> RecentConnections +} diff --git a/ios/MullvadSettings/SettingsStore.swift b/ios/MullvadSettings/SettingsStore.swift index 78e35e3258..fea9e8d183 100644 --- a/ios/MullvadSettings/SettingsStore.swift +++ b/ios/MullvadSettings/SettingsStore.swift @@ -16,6 +16,7 @@ public enum SettingsKey: String, CaseIterable, Sendable { case customRelayLists = "CustomRelayLists" case lastUsedAccount = "LastUsedAccount" case shouldWipeSettings = "ShouldWipeSettings" + case recentConnections = "RecentConnections" } public protocol SettingsStore: Sendable { diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index 2c9b6df726..631f52e6bc 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -978,6 +978,9 @@ F050AE5E2B739A73003F4EDB /* LocationDataSourceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE5D2B739A73003F4EDB /* LocationDataSourceProtocol.swift */; }; F050AE602B73A41E003F4EDB /* AllLocationDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE5F2B73A41E003F4EDB /* AllLocationDataSource.swift */; }; F050AE622B74DBAC003F4EDB /* CustomListsDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE612B74DBAC003F4EDB /* CustomListsDataSource.swift */; }; + F053601D2EA03D8A00D6EDFF /* RecentConnectionsRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00FEA192E9FC0A600F7EC6C /* RecentConnectionsRepository.swift */; }; + F053601E2EA03D8A00D6EDFF /* RecentConnections.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00FEA1D2E9FCF2D00F7EC6C /* RecentConnections.swift */; }; + F053601F2EA03D8A00D6EDFF /* RecentConnectionsRepositoryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00FEA1F2E9FCF7C00F7EC6C /* RecentConnectionsRepositoryProtocol.swift */; }; F0570CD12C4FB8E1007BDF2D /* EphemeralPeerExchangingPipeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = F05919762C453FAF00C301F3 /* EphemeralPeerExchangingPipeline.swift */; }; F0570CD22C4FB8E1007BDF2D /* SingleHopEphemeralPeerExchanger.swift in Sources */ = {isa = PBXBuildFile; fileRef = F05919782C45402E00C301F3 /* SingleHopEphemeralPeerExchanger.swift */; }; F0570CD42C4FB8E1007BDF2D /* MultiHopEphemeralPeerExchanger.swift in Sources */ = {isa = PBXBuildFile; fileRef = F059197C2C454C9200C301F3 /* MultiHopEphemeralPeerExchanger.swift */; }; @@ -1097,6 +1100,7 @@ F0E8E4C32A602E0D00ED26A3 /* AccountDeletionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0E8E4C22A602E0D00ED26A3 /* AccountDeletionViewModel.swift */; }; F0EF50D52A949F8E0031E8DF /* ChangeLogViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0EF50D42A949F8E0031E8DF /* ChangeLogViewModel.swift */; }; F0F146942D9462E100BF78E7 /* RustProblemReportRequestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0F146912D94491200BF78E7 /* RustProblemReportRequestTests.swift */; }; + F0F313A72E85321D00D55C43 /* RecentConnectionsRepositoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0F313A62E85320D00D55C43 /* RecentConnectionsRepositoryTests.swift */; }; F0F316192BF3572B0078DBCF /* RelaySelectorResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0F316182BF3572B0078DBCF /* RelaySelectorResult.swift */; }; F0F3161B2BF358590078DBCF /* NoRelaysSatisfyingConstraintsError.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0F3161A2BF358590078DBCF /* NoRelaysSatisfyingConstraintsError.swift */; }; F0F56B092C0E058A009D676B /* ObserverList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CC40EE24A601900019D96E /* ObserverList.swift */; }; @@ -2410,6 +2414,9 @@ E158B35F285381C60002F069 /* String+AccountFormatting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+AccountFormatting.swift"; sourceTree = "<group>"; }; E1FD0DF428AA7CE400299DB4 /* StatusActivityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusActivityView.swift; sourceTree = "<group>"; }; F006CCFB2B99CC8400C6C2AC /* EditLocationsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditLocationsCoordinator.swift; sourceTree = "<group>"; }; + F00FEA192E9FC0A600F7EC6C /* RecentConnectionsRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentConnectionsRepository.swift; sourceTree = "<group>"; }; + F00FEA1D2E9FCF2D00F7EC6C /* RecentConnections.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentConnections.swift; sourceTree = "<group>"; }; + F00FEA1F2E9FCF7C00F7EC6C /* RecentConnectionsRepositoryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentConnectionsRepositoryProtocol.swift; sourceTree = "<group>"; }; F011221C2E4A3275005DD108 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/InfoPlist.strings; sourceTree = "<group>"; }; F01528BA2BFF3FEE00B01D00 /* ShadowsocksRelaySelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShadowsocksRelaySelector.swift; sourceTree = "<group>"; }; F0164EB92B4456D30020268D /* AccessMethodRepository+Stub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccessMethodRepository+Stub.swift"; sourceTree = "<group>"; }; @@ -2555,6 +2562,7 @@ F0EF50D42A949F8E0031E8DF /* ChangeLogViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChangeLogViewModel.swift; sourceTree = "<group>"; }; F0F146912D94491200BF78E7 /* RustProblemReportRequestTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RustProblemReportRequestTests.swift; sourceTree = "<group>"; }; F0F1EF8C2BE8FF0A00CED01D /* LaunchArguments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchArguments.swift; sourceTree = "<group>"; }; + F0F313A62E85320D00D55C43 /* RecentConnectionsRepositoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentConnectionsRepositoryTests.swift; sourceTree = "<group>"; }; F0F316182BF3572B0078DBCF /* RelaySelectorResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelaySelectorResult.swift; sourceTree = "<group>"; }; F0F3161A2BF358590078DBCF /* NoRelaysSatisfyingConstraintsError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NoRelaysSatisfyingConstraintsError.swift; sourceTree = "<group>"; }; F0FA16082D7F0413007E2546 /* FilterDescriptorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterDescriptorTests.swift; sourceTree = "<group>"; }; @@ -3071,8 +3079,8 @@ 581943F228F8014500B0CB5E /* MullvadTypes */ = { isa = PBXGroup; children = ( - F0DDE4132B220458006B57A7 /* ShadowsocksConfiguration.swift */, 588D7ED72AF3A533005DF40A /* AccessMethodKind.swift */, + A97687C72DD60D5D000D96E8 /* AddressCacheProviding.swift */, 584D26BE270C550B004EA533 /* AnyIPAddress.swift */, 586A951329013235007BAF2B /* AnyIPEndpoint.swift */, 06AC113628F83FD70037AF9A /* Cancellable.swift */, @@ -3105,14 +3113,14 @@ 5898D2AF2902A67C00EB5EBA /* RelayLocation.swift */, 581DA2722A1E227D0046ED47 /* RESTTypes.swift */, 58F1311427E0B2AB007AC5BC /* Result+Extensions.swift */, + A98207EE2D9192A300654558 /* ShadowsocksBridgeProviding.swift */, 58DFF7D92B02862E00F864E0 /* ShadowsocksCipherOptions.swift */, + F0DDE4132B220458006B57A7 /* ShadowsocksConfiguration.swift */, F924C5A32DA65F28001F4660 /* Storekit2.swift */, F0ADF1CC2CFDFF3100299F09 /* StringConversionError.swift */, + A9711B2A2D662AE3003DA71D /* SwiftConnectionModeProvider.swift */, A91614D02B108D1B00F416EB /* TransportLayer.swift */, 58E511E028DDB7F100B0BCDE /* WrappingError.swift */, - A9711B2A2D662AE3003DA71D /* SwiftConnectionModeProvider.swift */, - A98207EE2D9192A300654558 /* ShadowsocksBridgeProviding.swift */, - A97687C72DD60D5D000D96E8 /* AddressCacheProviding.swift */, ); path = MullvadTypes; sourceTree = "<group>"; @@ -3804,6 +3812,9 @@ 58B2FDD52AA71D2A003EB5C6 /* MullvadSettings.h */, F0E61CA92BF2911D000C4A95 /* MultihopSettings.swift */, 44DD7D2C2B74E44A0005F67F /* QuantumResistanceSettings.swift */, + F00FEA1D2E9FCF2D00F7EC6C /* RecentConnections.swift */, + F00FEA192E9FC0A600F7EC6C /* RecentConnectionsRepository.swift */, + F00FEA1F2E9FCF7C00F7EC6C /* RecentConnectionsRepositoryProtocol.swift */, 58FF2C02281BDE02009EF542 /* SettingsManager.swift */, 06410E03292D0F7100AFC18C /* SettingsParser.swift */, 06410E06292D108E00AFC18C /* SettingsStore.swift */, @@ -4444,9 +4455,10 @@ isa = PBXGroup; children = ( 7A9BE5AC2B90DF2D00E2A7D0 /* AllLocationsDataSourceTests.swift */, - 7A9BE5A42B90760C00E2A7D0 /* CustomListsDataSourceTests.swift */, F050AE5B2B73797D003F4EDB /* CustomListRepositoryTests.swift */, + 7A9BE5A42B90760C00E2A7D0 /* CustomListsDataSourceTests.swift */, 7A9BE5A12B8F88C500E2A7D0 /* LocationNodeTests.swift */, + F0F313A62E85320D00D55C43 /* RecentConnectionsRepositoryTests.swift */, ); path = SelectLocation; sourceTree = "<group>"; @@ -6212,6 +6224,7 @@ F073FCB32C6617D70062EA1D /* TunnelStore+Stubs.swift in Sources */, 58DFF7D32B02570000F864E0 /* MarkdownStylingOptions.swift in Sources */, A9A5FA342ACB05160083449F /* StringTests.swift in Sources */, + F0F313A72E85321D00D55C43 /* RecentConnectionsRepositoryTests.swift in Sources */, 7A52F96C2C17450C00B133B9 /* RelaySelectorWrapperTests.swift in Sources */, A9A5FA352ACB05160083449F /* WgKeyRotationTests.swift in Sources */, F0A7EBB62CF092CC005BB671 /* ApplicationConfiguration.swift in Sources */, @@ -6261,6 +6274,9 @@ 7A5869BC2B56EF3400640D27 /* IPOverrideRepository.swift in Sources */, 7A9F293B2CAC4443005F2089 /* InfoHeaderConfig.swift in Sources */, F0E61CAB2BF2911D000C4A95 /* MultihopSettings.swift in Sources */, + F053601D2EA03D8A00D6EDFF /* RecentConnectionsRepository.swift in Sources */, + F053601E2EA03D8A00D6EDFF /* RecentConnections.swift in Sources */, + F053601F2EA03D8A00D6EDFF /* RecentConnectionsRepositoryProtocol.swift in Sources */, 58B2FDE82AA71D5C003EB5C6 /* KeychainSettingsStore.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/ios/MullvadVPNTests/MullvadSettings/InMemorySettingsStore.swift b/ios/MullvadVPNTests/MullvadSettings/InMemorySettingsStore.swift index dcdb108a9b..f62b3b1dcb 100644 --- a/ios/MullvadVPNTests/MullvadSettings/InMemorySettingsStore.swift +++ b/ios/MullvadVPNTests/MullvadSettings/InMemorySettingsStore.swift @@ -15,21 +15,30 @@ protocol Instantiable { class InMemorySettingsStore<ThrownError: Error>: SettingsStore, @unchecked Sendable where ThrownError: Instantiable { private var settings = [SettingsKey: Data]() + let queue = DispatchQueue(label: "com.mullvad.vpn.tests.inMemorySettingsStore") func read(key: SettingsKey) throws -> Data { - guard settings.keys.contains(key), let value = settings[key] else { throw ThrownError() } - return value + try queue.sync { + guard let value = settings[key] else { throw ThrownError() } + return value + } } func write(_ data: Data, for key: SettingsKey) throws { - settings[key] = data + queue.sync { + self.settings[key] = data + } } func delete(key: SettingsKey) throws { - settings.removeValue(forKey: key) + queue.sync { + _ = self.settings.removeValue(forKey: key) + } } func reset() { - settings.removeAll() + queue.sync { + self.settings.removeAll() + } } } diff --git a/ios/MullvadVPNTests/MullvadVPN/View controllers/SelectLocation/RecentConnectionsRepositoryTests.swift b/ios/MullvadVPNTests/MullvadVPN/View controllers/SelectLocation/RecentConnectionsRepositoryTests.swift new file mode 100644 index 0000000000..2d63e64563 --- /dev/null +++ b/ios/MullvadVPNTests/MullvadVPN/View controllers/SelectLocation/RecentConnectionsRepositoryTests.swift @@ -0,0 +1,95 @@ +// +// RecentConnectionsRepositoryTests.swift +// MullvadVPN +// +// Created by Mojgan on 2025-09-25. +// Copyright © 2025 Mullvad VPN AB. All rights reserved. +// +import Testing + +@testable import MullvadSettings +@testable import MullvadTypes + +@Suite("RecentConnectionsRepositoryTests") +final class RecentConnectionsRepositoryTests { + let se = UserSelectedRelays(locations: [.country("se")]) + let fr = UserSelectedRelays(locations: [.country("fr")]) + let nl = UserSelectedRelays(locations: [.country("nl")]) + let de = UserSelectedRelays(locations: [.country("de")]) + + @Test("Adds locations up to the limit 1 for either entry or exit") + func addLocations() throws { + let maxLimit: UInt = 1 + let repository = makeRepository(max: maxLimit) + try repository.setRecentsEnabled(true) + try addLocations(repository, locations: [se, de], as: .entry) + try addLocations(repository, locations: [de], as: .exit) + + let recentsSettings = try repository.all() + #expect(recentsSettings.isEnabled) + #expect(recentsSettings.exitLocations.count == maxLimit) + #expect(recentsSettings.entryLocations.count == maxLimit) + } + + @Test("Adds locations up to the default limit (50) for either entry or exit") + func addDuplicate() throws { + let repository = makeRepository() + try repository.setRecentsEnabled(true) + try addLocations(repository, locations: [se, de], as: .entry) + try addLocations(repository, locations: [de, se, nl, se], as: .exit) + + let recentsSettings = try repository.all() + #expect(recentsSettings.isEnabled) + #expect(recentsSettings.entryLocations.count == 2) + #expect(recentsSettings.exitLocations.count == 3) + } + + @Test("Removes all recents connections with disabling recents") + func disable() throws { + let repository = makeRepository() + try repository.setRecentsEnabled(true) + try addLocations(repository, locations: [se, de], as: .entry) + try addLocations(repository, locations: [de, se, nl], as: .exit) + + var recentConnections = try repository.all() + #expect(recentConnections.isEnabled) + #expect(recentConnections.entryLocations.count == 2) + #expect(recentConnections.exitLocations.count == 3) + + try repository.setRecentsEnabled(false) + + recentConnections = try repository.all() + #expect(!recentConnections.isEnabled) + #expect(recentConnections.entryLocations.count == 0) + #expect(recentConnections.exitLocations.count == 0) + + } + + @Test("Fails with an error if a location is added while recents are disabled.") + func addRecentsBeforeEnablingRecents() throws { + let repository = makeRepository() + + try repository.setRecentsEnabled(false) + let action: () throws -> Void = { [self] in + try addLocations( + repository, + locations: [self.se], + as: RecentLocationType.entry + ) + } + + #expect(throws: RecentConnectionsRepositoryError.recentsDisabled, performing: action) + } + + private func makeRepository(max: UInt = 50) -> RecentConnectionsRepository { + return RecentConnectionsRepository(store: InMemorySettingsStore<SettingNotFound>(), maxLimit: max) + } + + private func addLocations( + _ repository: RecentConnectionsRepository, locations: [UserSelectedRelays], as type: RecentLocationType + ) throws { + for location in locations { + try repository.add(location, as: type) + } + } +} |
