diff --git a/ios/MullvadSettings/CustomList.swift b/ios/MullvadSettings/CustomList.swift new file mode 100644 index 000000000000..51066c7281b7 --- /dev/null +++ b/ios/MullvadSettings/CustomList.swift @@ -0,0 +1,20 @@ +// +// CustomList.swift +// MullvadVPN +// +// Created by Mojgan on 2024-01-25. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import MullvadTypes + +public struct CustomList: Codable, Equatable { + public let id: UUID + public var name: String + public var list: [RelayLocation] = [] + public init(id: UUID, name: String) { + self.id = id + self.name = name + } +} diff --git a/ios/MullvadSettings/CustomListRepository.swift b/ios/MullvadSettings/CustomListRepository.swift new file mode 100644 index 000000000000..e900ff355c76 --- /dev/null +++ b/ios/MullvadSettings/CustomListRepository.swift @@ -0,0 +1,103 @@ +// +// CustomListRepository.swift +// MullvadVPN +// +// Created by Mojgan on 2024-01-25. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Combine +import Foundation +import MullvadLogging +import MullvadTypes + +public enum CustomRelayListError: LocalizedError, Equatable { + case duplicateName + + public var errorDescription: String? { + switch self { + case .duplicateName: + NSLocalizedString( + "DUPLICATE_CUSTOM_LIST_ERROR", + tableName: "CustomListRepository", + value: "Name is already taken.", + comment: "" + ) + } + } +} + +public struct CustomListRepository: CustomListRepositoryProtocol { + public var publisher: AnyPublisher<[CustomList], Never> { + passthroughSubject.eraseToAnyPublisher() + } + + private let logger = Logger(label: "CustomListRepository") + private let passthroughSubject = PassthroughSubject<[CustomList], Never>() + + private let settingsParser: SettingsParser = { + SettingsParser(decoder: JSONDecoder(), encoder: JSONEncoder()) + }() + + public init() {} + + public func create(_ name: String) throws -> CustomList { + var lists = fetchAll() + if lists.contains(where: { $0.name == name }) { + throw CustomRelayListError.duplicateName + } else { + let item = CustomList(id: UUID(), name: name) + lists.append(item) + try write(lists) + return item + } + } + + public func delete(id: UUID) { + do { + var lists = fetchAll() + if let index = lists.firstIndex(where: { $0.id == id }) { + lists.remove(at: index) + try write(lists) + } + } catch { + logger.error(error: error) + } + } + + public func fetch(by id: UUID) -> CustomList? { + try? read().first(where: { $0.id == id }) + } + + public func fetchAll() -> [CustomList] { + (try? read()) ?? [] + } + + public func update(_ list: CustomList) { + do { + var lists = fetchAll() + if let index = lists.firstIndex(where: { $0.id == list.id }) { + lists[index] = list + try write(lists) + } + } catch { + logger.error(error: error) + } + } +} + +extension CustomListRepository { + private func read() throws -> [CustomList] { + let data = try SettingsManager.store.read(key: .customRelayLists) + + return try settingsParser.parseUnversionedPayload(as: [CustomList].self, from: data) + } + + private func write(_ list: [CustomList]) throws { + let data = try settingsParser.produceUnversionedPayload(list) + + try SettingsManager.store.write(data, for: .customRelayLists) + + passthroughSubject.send(list) + } +} diff --git a/ios/MullvadSettings/CustomListRepositoryProtocol.swift b/ios/MullvadSettings/CustomListRepositoryProtocol.swift new file mode 100644 index 000000000000..42c498d45238 --- /dev/null +++ b/ios/MullvadSettings/CustomListRepositoryProtocol.swift @@ -0,0 +1,37 @@ +// +// CustomListRepositoryProtocol.swift +// MullvadVPN +// +// Created by Mojgan on 2024-01-25. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Combine +import Foundation +import MullvadTypes +public protocol CustomListRepositoryProtocol { + /// Publisher that propagates a snapshot of persistent store upon modifications. + var publisher: AnyPublisher<[CustomList], Never> { get } + + /// Persist modified custom list locating existing entry by id. + /// - Parameter list: persistent custom list model. + func update(_ list: CustomList) + + /// Delete custom list by id. + /// - Parameter id: an access method id. + func delete(id: UUID) + + /// Fetch custom list by id. + /// - Parameter id: a custom list id. + /// - Returns: a persistent custom list model upon success, otherwise `nil`. + func fetch(by id: UUID) -> CustomList? + + /// Create a custom list by unique name. + /// - Parameter name: a custom list name. + /// - Returns: a persistent custom list model upon success, otherwise throws `Error`. + func create(_ name: String) throws -> CustomList + + /// Fetch all custom list. + /// - Returns: all custom list model . + func fetchAll() -> [CustomList] +} diff --git a/ios/MullvadSettings/SettingsStore.swift b/ios/MullvadSettings/SettingsStore.swift index 0b4c98dbb655..2901ae2bb3b9 100644 --- a/ios/MullvadSettings/SettingsStore.swift +++ b/ios/MullvadSettings/SettingsStore.swift @@ -13,6 +13,7 @@ public enum SettingsKey: String, CaseIterable { case deviceState = "DeviceState" case apiAccessMethods = "ApiAccessMethods" case ipOverrides = "IPOverrides" + case customRelayLists = "CustomRelayLists" case lastUsedAccount = "LastUsedAccount" case shouldWipeSettings = "ShouldWipeSettings" } diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index efda36cf7933..17a4b9ae7fa4 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -785,6 +785,10 @@ F03580252A13842C00E5DAFD /* IncreasedHitButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = F03580242A13842C00E5DAFD /* IncreasedHitButton.swift */; }; F04F95A12B21D24400431E08 /* shadowsocks.h in Headers */ = {isa = PBXBuildFile; fileRef = F04F95A02B21D24400431E08 /* shadowsocks.h */; settings = {ATTRIBUTES = (Private, ); }; }; F04FBE612A8379EE009278D7 /* AppPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = F04FBE602A8379EE009278D7 /* AppPreferences.swift */; }; + F050AE572B7376C6003F4EDB /* CustomListRepositoryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE552B7376C5003F4EDB /* CustomListRepositoryProtocol.swift */; }; + F050AE582B7376C6003F4EDB /* CustomListRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE562B7376C6003F4EDB /* CustomListRepository.swift */; }; + F050AE5A2B7376F4003F4EDB /* CustomList.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE592B7376F4003F4EDB /* CustomList.swift */; }; + F050AE5C2B73797D003F4EDB /* CustomListRepositoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE5B2B73797D003F4EDB /* CustomListRepositoryTests.swift */; }; F05F39942B21C6C6006E60A7 /* relays.json in Resources */ = {isa = PBXBuildFile; fileRef = 58F3C0A524A50155003E76BE /* relays.json */; }; F05F39972B21C735006E60A7 /* RelayCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5820675A26E6576800655B05 /* RelayCache.swift */; }; F05F39982B21C73C006E60A7 /* CachedRelays.swift in Sources */ = {isa = PBXBuildFile; fileRef = 585DA87626B024A600B8C587 /* CachedRelays.swift */; }; @@ -1890,6 +1894,10 @@ F03580242A13842C00E5DAFD /* IncreasedHitButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncreasedHitButton.swift; sourceTree = ""; }; F04F95A02B21D24400431E08 /* shadowsocks.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = shadowsocks.h; sourceTree = ""; }; F04FBE602A8379EE009278D7 /* AppPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPreferences.swift; sourceTree = ""; }; + F050AE552B7376C5003F4EDB /* CustomListRepositoryProtocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomListRepositoryProtocol.swift; sourceTree = ""; }; + F050AE562B7376C6003F4EDB /* CustomListRepository.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomListRepository.swift; sourceTree = ""; }; + F050AE592B7376F4003F4EDB /* CustomList.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomList.swift; sourceTree = ""; }; + F050AE5B2B73797D003F4EDB /* CustomListRepositoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomListRepositoryTests.swift; sourceTree = ""; }; F06045E52B231EB700B2D37A /* URLSessionTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionTransport.swift; sourceTree = ""; }; F06045E92B23217E00B2D37A /* ShadowsocksTransport.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShadowsocksTransport.swift; sourceTree = ""; }; F06045EB2B2322A500B2D37A /* Jittered.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Jittered.swift; sourceTree = ""; }; @@ -2809,6 +2817,7 @@ A900E9BD2ACC654100C95F67 /* APIProxy+Stubs.swift */, A9EC20E72A5D3A8C0040D56E /* CoordinatesTests.swift */, 5896AE85246D6AD8005B36CB /* CustomDateComponentsFormattingTests.swift */, + F050AE5B2B73797D003F4EDB /* CustomListRepositoryTests.swift */, 58915D622A25F8400066445B /* DeviceCheckOperationTests.swift */, A900E9BB2ACC609200C95F67 /* DevicesProxy+Stubs.swift */, 58FBFBF0291630700020E046 /* DurationTests.swift */, @@ -2878,6 +2887,9 @@ 5827B0A02B0E064E00CCBBA1 /* AccessMethodRepository.swift */, 58EF875A2B16385400C098B2 /* AccessMethodRepositoryProtocol.swift */, F0164EBB2B482E430020268D /* AppStorage.swift */, + F050AE592B7376F4003F4EDB /* CustomList.swift */, + F050AE562B7376C6003F4EDB /* CustomListRepository.swift */, + F050AE552B7376C5003F4EDB /* CustomListRepositoryProtocol.swift */, A92ECC2B2A7803A50052F1B1 /* DeviceState.swift */, 580F8B8528197958002E0998 /* DNSSettings.swift */, 7A5869B22B5697AC00640D27 /* IPOverride.swift */, @@ -4678,6 +4690,7 @@ F09D04B72AE941DA003D4F89 /* OutgoingConnectionProxyTests.swift in Sources */, F09D04B92AE95111003D4F89 /* OutgoingConnectionProxy.swift in Sources */, 7A6000F92B6273A4001CF0D9 /* AccessMethodViewModel.swift in Sources */, + F050AE5C2B73797D003F4EDB /* CustomListRepositoryTests.swift in Sources */, A9A5F9F62ACB05160083449F /* TunnelStatusNotificationProvider.swift in Sources */, A9A5F9F72ACB05160083449F /* NotificationProviderProtocol.swift in Sources */, A9A5F9F82ACB05160083449F /* NotificationProviderIdentifier.swift in Sources */, @@ -4773,8 +4786,10 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + F050AE582B7376C6003F4EDB /* CustomListRepository.swift in Sources */, 7A5869BD2B56EF7300640D27 /* IPOverride.swift in Sources */, 58B2FDEE2AA72098003EB5C6 /* ApplicationConfiguration.swift in Sources */, + F050AE572B7376C6003F4EDB /* CustomListRepositoryProtocol.swift in Sources */, 58B2FDE52AA71D5C003EB5C6 /* TunnelSettingsV2.swift in Sources */, A97D30172AE6B5E90045C0E4 /* StoredWgKeyData.swift in Sources */, F08827882B318F960020A383 /* PersistentAccessMethod.swift in Sources */, @@ -4798,6 +4813,7 @@ F08827872B318C840020A383 /* ShadowsocksCipherOptions.swift in Sources */, 58B2FDE92AA71D5C003EB5C6 /* SettingsParser.swift in Sources */, F08827892B3192110020A383 /* AccessMethodRepositoryProtocol.swift in Sources */, + F050AE5A2B7376F4003F4EDB /* CustomList.swift in Sources */, 58B2FDE22AA71D5C003EB5C6 /* StoredAccountData.swift in Sources */, F0D7FF902B31E00B00E0FDE5 /* AccessMethodKind.swift in Sources */, 7A5869BC2B56EF3400640D27 /* IPOverrideRepository.swift in Sources */, diff --git a/ios/MullvadVPNTests/CustomListRepositoryTests.swift b/ios/MullvadVPNTests/CustomListRepositoryTests.swift new file mode 100644 index 000000000000..d7b80a63742b --- /dev/null +++ b/ios/MullvadVPNTests/CustomListRepositoryTests.swift @@ -0,0 +1,79 @@ +// +// CustomListRepositoryTests.swift +// MullvadVPNTests +// +// Created by Mojgan on 2024-02-07. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +@testable import MullvadSettings +import Network +import XCTest + +class CustomListRepositoryTests: XCTestCase { + static let store = InMemorySettingsStore() + private var repository = CustomListRepository() + + override class func setUp() { + SettingsManager.unitTestStore = store + } + + override class func tearDown() { + SettingsManager.unitTestStore = nil + } + + override func tearDownWithError() throws { + repository.fetchAll().forEach { + repository.delete(id: $0.id) + } + } + + func testFailedAddingDuplicateCustomList() throws { + let name = "Netflix" + let item = try XCTUnwrap(repository.create(name)) + XCTAssertThrowsError(try repository.create(item.name)) { error in + XCTAssertEqual(error as? CustomRelayListError, CustomRelayListError.duplicateName) + } + } + + func testAddingCustomList() throws { + let name = "Netflix" + + var item = try XCTUnwrap(repository.create(name)) + item.list.append(.country("SE")) + item.list.append(.city("SE", "Gothenburg")) + + repository.update(item) + + let storedItem = repository.fetch(by: item.id) + XCTAssertEqual(storedItem, item) + } + + func testDeletingCustomList() throws { + let name = "Netflix" + + var item = try XCTUnwrap(repository.create(name)) + item.list.append(.country("SE")) + item.list.append(.city("SE", "Gothenburg")) + repository.update(item) + + let storedItem = repository.fetch(by: item.id) + repository.delete(id: try XCTUnwrap(storedItem?.id)) + + XCTAssertNil(repository.fetch(by: item.id)) + } + + func testFetchingAllCustomList() throws { + var streaming = try XCTUnwrap(repository.create("Netflix")) + streaming.list.append(.country("FR")) + streaming.list.append(.city("SE", "Gothenburg")) + repository.update(streaming) + + var gaming = try XCTUnwrap(repository.create("PS5")) + gaming.list.append(.country("DE")) + gaming.list.append(.city("SE", "Gothenburg")) + repository.update(streaming) + + XCTAssertEqual(repository.fetchAll().count, 2) + } +}