Skip to content

Commit a2bab4b

Browse files
committed
storing custom list into settings
1 parent 446ef2b commit a2bab4b

File tree

6 files changed

+252
-0
lines changed

6 files changed

+252
-0
lines changed

ios/MullvadSettings/CustomList.swift

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
//
2+
// CustomList.swift
3+
// MullvadVPN
4+
//
5+
// Created by Mojgan on 2024-01-25.
6+
// Copyright © 2024 Mullvad VPN AB. All rights reserved.
7+
//
8+
9+
import Foundation
10+
import MullvadTypes
11+
12+
public struct CustomList: Codable, Equatable {
13+
public let id: UUID
14+
public var name: String
15+
public var list: [RelayLocation] = []
16+
public init(id: UUID, name: String) {
17+
self.id = id
18+
self.name = name
19+
}
20+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
//
2+
// CustomListRepository.swift
3+
// MullvadVPN
4+
//
5+
// Created by Mojgan on 2024-01-25.
6+
// Copyright © 2024 Mullvad VPN AB. All rights reserved.
7+
//
8+
9+
import Combine
10+
import Foundation
11+
import MullvadLogging
12+
import MullvadTypes
13+
14+
public enum CustomRelayListError: LocalizedError, Equatable {
15+
case duplicateName
16+
17+
public var errorDescription: String? {
18+
switch self {
19+
case .duplicateName:
20+
"Name is already taken."
21+
}
22+
}
23+
}
24+
25+
public struct CustomListRepository: CustomListRepositoryProtocol {
26+
public var publisher: AnyPublisher<[CustomList], Never> {
27+
passthroughSubject.eraseToAnyPublisher()
28+
}
29+
30+
private let logger = Logger(label: "CustomListRepository")
31+
private let passthroughSubject = PassthroughSubject<[CustomList], Never>()
32+
33+
private let settingsParser: SettingsParser = {
34+
SettingsParser(decoder: JSONDecoder(), encoder: JSONEncoder())
35+
}()
36+
37+
public init() {}
38+
39+
public func create(_ name: String) throws -> CustomList {
40+
do {
41+
var lists = fetchAll()
42+
if lists.contains(where: { $0.name == name }) {
43+
throw CustomRelayListError.duplicateName
44+
} else {
45+
let item = CustomList(id: UUID(), name: name)
46+
lists.append(item)
47+
try write(lists)
48+
return item
49+
}
50+
} catch {
51+
throw error
52+
}
53+
}
54+
55+
public func delete(id: UUID) {
56+
do {
57+
var lists = fetchAll()
58+
if let index = lists.firstIndex(where: { $0.id == id }) {
59+
lists.remove(at: index)
60+
try write(lists)
61+
}
62+
} catch {
63+
logger.error(error: error)
64+
}
65+
}
66+
67+
public func fetch(by id: UUID) -> CustomList? {
68+
try? read().first(where: { $0.id == id })
69+
}
70+
71+
public func fetchAll() -> [CustomList] {
72+
(try? read()) ?? []
73+
}
74+
75+
public func update(_ list: CustomList) {
76+
do {
77+
var lists = fetchAll()
78+
if let index = lists.firstIndex(where: { $0.id == list.id }) {
79+
lists[index] = list
80+
try write(lists)
81+
}
82+
} catch {
83+
logger.error(error: error)
84+
}
85+
}
86+
}
87+
88+
extension CustomListRepository {
89+
private func read() throws -> [CustomList] {
90+
let data = try SettingsManager.store.read(key: .customRelayLists)
91+
92+
return try settingsParser.parseUnversionedPayload(as: [CustomList].self, from: data)
93+
}
94+
95+
private func write(_ list: [CustomList]) throws {
96+
let data = try settingsParser.produceUnversionedPayload(list)
97+
98+
try SettingsManager.store.write(data, for: .customRelayLists)
99+
100+
passthroughSubject.send(list)
101+
}
102+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
//
2+
// CustomListRepositoryProtocol.swift
3+
// MullvadVPN
4+
//
5+
// Created by Mojgan on 2024-01-25.
6+
// Copyright © 2024 Mullvad VPN AB. All rights reserved.
7+
//
8+
9+
import Combine
10+
import Foundation
11+
import MullvadTypes
12+
public protocol CustomListRepositoryProtocol {
13+
/// Publisher that propagates a snapshot of persistent store upon modifications.
14+
var publisher: AnyPublisher<[CustomList], Never> { get }
15+
16+
/// Persist modified custom list locating existing entry by id.
17+
/// - Parameter list: persistent custom list model.
18+
func update(_ list: CustomList)
19+
20+
/// Delete custom list by id.
21+
/// - Parameter id: an access method id.
22+
func delete(id: UUID)
23+
24+
/// Fetch custom list by id.
25+
/// - Parameter id: a custom list id.
26+
/// - Returns: a persistent custom list model upon success, otherwise `nil`.
27+
func fetch(by id: UUID) -> CustomList?
28+
29+
/// Create a custom list by unique name.
30+
/// - Parameter name: a custom list name.
31+
/// - Returns: a persistent custom list model upon success, otherwise throws `Error`.
32+
func create(_ name: String) throws -> CustomList
33+
34+
/// Fetch all custom list.
35+
/// - Returns: all custom list model .
36+
func fetchAll() -> [CustomList]
37+
}

ios/MullvadSettings/SettingsStore.swift

+1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ public enum SettingsKey: String, CaseIterable {
1313
case deviceState = "DeviceState"
1414
case apiAccessMethods = "ApiAccessMethods"
1515
case ipOverrides = "IPOverrides"
16+
case customRelayLists = "CustomRelayLists"
1617
case lastUsedAccount = "LastUsedAccount"
1718
case shouldWipeSettings = "ShouldWipeSettings"
1819
}

ios/MullvadVPN.xcodeproj/project.pbxproj

+16
Original file line numberDiff line numberDiff line change
@@ -763,6 +763,10 @@
763763
F03580252A13842C00E5DAFD /* IncreasedHitButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = F03580242A13842C00E5DAFD /* IncreasedHitButton.swift */; };
764764
F04F95A12B21D24400431E08 /* shadowsocks.h in Headers */ = {isa = PBXBuildFile; fileRef = F04F95A02B21D24400431E08 /* shadowsocks.h */; settings = {ATTRIBUTES = (Private, ); }; };
765765
F04FBE612A8379EE009278D7 /* AppPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = F04FBE602A8379EE009278D7 /* AppPreferences.swift */; };
766+
F050AE572B7376C6003F4EDB /* CustomListRepositoryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE552B7376C5003F4EDB /* CustomListRepositoryProtocol.swift */; };
767+
F050AE582B7376C6003F4EDB /* CustomListRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE562B7376C6003F4EDB /* CustomListRepository.swift */; };
768+
F050AE5A2B7376F4003F4EDB /* CustomList.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE592B7376F4003F4EDB /* CustomList.swift */; };
769+
F050AE5C2B73797D003F4EDB /* CustomListRepositoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE5B2B73797D003F4EDB /* CustomListRepositoryTests.swift */; };
766770
F05F39942B21C6C6006E60A7 /* relays.json in Resources */ = {isa = PBXBuildFile; fileRef = 58F3C0A524A50155003E76BE /* relays.json */; };
767771
F05F39972B21C735006E60A7 /* RelayCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5820675A26E6576800655B05 /* RelayCache.swift */; };
768772
F05F39982B21C73C006E60A7 /* CachedRelays.swift in Sources */ = {isa = PBXBuildFile; fileRef = 585DA87626B024A600B8C587 /* CachedRelays.swift */; };
@@ -1843,6 +1847,10 @@
18431847
F03580242A13842C00E5DAFD /* IncreasedHitButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncreasedHitButton.swift; sourceTree = "<group>"; };
18441848
F04F95A02B21D24400431E08 /* shadowsocks.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = shadowsocks.h; sourceTree = "<group>"; };
18451849
F04FBE602A8379EE009278D7 /* AppPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPreferences.swift; sourceTree = "<group>"; };
1850+
F050AE552B7376C5003F4EDB /* CustomListRepositoryProtocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomListRepositoryProtocol.swift; sourceTree = "<group>"; };
1851+
F050AE562B7376C6003F4EDB /* CustomListRepository.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomListRepository.swift; sourceTree = "<group>"; };
1852+
F050AE592B7376F4003F4EDB /* CustomList.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomList.swift; sourceTree = "<group>"; };
1853+
F050AE5B2B73797D003F4EDB /* CustomListRepositoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomListRepositoryTests.swift; sourceTree = "<group>"; };
18461854
F06045E52B231EB700B2D37A /* URLSessionTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionTransport.swift; sourceTree = "<group>"; };
18471855
F06045E92B23217E00B2D37A /* ShadowsocksTransport.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShadowsocksTransport.swift; sourceTree = "<group>"; };
18481856
F06045EB2B2322A500B2D37A /* Jittered.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Jittered.swift; sourceTree = "<group>"; };
@@ -2737,6 +2745,7 @@
27372745
A900E9BD2ACC654100C95F67 /* APIProxy+Stubs.swift */,
27382746
A9EC20E72A5D3A8C0040D56E /* CoordinatesTests.swift */,
27392747
5896AE85246D6AD8005B36CB /* CustomDateComponentsFormattingTests.swift */,
2748+
F050AE5B2B73797D003F4EDB /* CustomListRepositoryTests.swift */,
27402749
58915D622A25F8400066445B /* DeviceCheckOperationTests.swift */,
27412750
A900E9BB2ACC609200C95F67 /* DevicesProxy+Stubs.swift */,
27422751
58FBFBF0291630700020E046 /* DurationTests.swift */,
@@ -2803,6 +2812,9 @@
28032812
5827B0A02B0E064E00CCBBA1 /* AccessMethodRepository.swift */,
28042813
58EF875A2B16385400C098B2 /* AccessMethodRepositoryProtocol.swift */,
28052814
F0164EBB2B482E430020268D /* AppStorage.swift */,
2815+
F050AE592B7376F4003F4EDB /* CustomList.swift */,
2816+
F050AE562B7376C6003F4EDB /* CustomListRepository.swift */,
2817+
F050AE552B7376C5003F4EDB /* CustomListRepositoryProtocol.swift */,
28062818
A92ECC2B2A7803A50052F1B1 /* DeviceState.swift */,
28072819
580F8B8528197958002E0998 /* DNSSettings.swift */,
28082820
7A5869B22B5697AC00640D27 /* IPOverride.swift */,
@@ -4549,6 +4561,7 @@
45494561
F09D04B72AE941DA003D4F89 /* OutgoingConnectionProxyTests.swift in Sources */,
45504562
F09D04B92AE95111003D4F89 /* OutgoingConnectionProxy.swift in Sources */,
45514563
7A6000F92B6273A4001CF0D9 /* AccessMethodViewModel.swift in Sources */,
4564+
F050AE5C2B73797D003F4EDB /* CustomListRepositoryTests.swift in Sources */,
45524565
A9A5F9F62ACB05160083449F /* TunnelStatusNotificationProvider.swift in Sources */,
45534566
A9A5F9F72ACB05160083449F /* NotificationProviderProtocol.swift in Sources */,
45544567
A9A5F9F82ACB05160083449F /* NotificationProviderIdentifier.swift in Sources */,
@@ -4639,8 +4652,10 @@
46394652
isa = PBXSourcesBuildPhase;
46404653
buildActionMask = 2147483647;
46414654
files = (
4655+
F050AE582B7376C6003F4EDB /* CustomListRepository.swift in Sources */,
46424656
7A5869BD2B56EF7300640D27 /* IPOverride.swift in Sources */,
46434657
58B2FDEE2AA72098003EB5C6 /* ApplicationConfiguration.swift in Sources */,
4658+
F050AE572B7376C6003F4EDB /* CustomListRepositoryProtocol.swift in Sources */,
46444659
58B2FDE52AA71D5C003EB5C6 /* TunnelSettingsV2.swift in Sources */,
46454660
A97D30172AE6B5E90045C0E4 /* StoredWgKeyData.swift in Sources */,
46464661
F08827882B318F960020A383 /* PersistentAccessMethod.swift in Sources */,
@@ -4661,6 +4676,7 @@
46614676
F08827872B318C840020A383 /* ShadowsocksCipherOptions.swift in Sources */,
46624677
58B2FDE92AA71D5C003EB5C6 /* SettingsParser.swift in Sources */,
46634678
F08827892B3192110020A383 /* AccessMethodRepositoryProtocol.swift in Sources */,
4679+
F050AE5A2B7376F4003F4EDB /* CustomList.swift in Sources */,
46644680
58B2FDE22AA71D5C003EB5C6 /* StoredAccountData.swift in Sources */,
46654681
F0D7FF902B31E00B00E0FDE5 /* AccessMethodKind.swift in Sources */,
46664682
7A5869BC2B56EF3400640D27 /* IPOverrideRepository.swift in Sources */,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
//
2+
// CustomListRepositoryTests.swift
3+
// MullvadVPNTests
4+
//
5+
// Created by Mojgan on 2024-02-07.
6+
// Copyright © 2024 Mullvad VPN AB. All rights reserved.
7+
//
8+
9+
@testable import MullvadSettings
10+
import Network
11+
import XCTest
12+
13+
class CustomListRepositoryTests: XCTestCase {
14+
static let store = InMemorySettingsStore<SettingNotFound>()
15+
private var repository = CustomListRepository()
16+
17+
override class func setUp() {
18+
SettingsManager.unitTestStore = store
19+
}
20+
21+
override class func tearDown() {
22+
SettingsManager.unitTestStore = nil
23+
}
24+
25+
override func tearDownWithError() throws {
26+
repository.fetchAll().forEach {
27+
repository.delete(id: $0.id)
28+
}
29+
}
30+
31+
func testFailedAddingDuplicateCustomList() throws {
32+
let name = "Netflix"
33+
let item = try XCTUnwrap(repository.create(name))
34+
XCTAssertThrowsError(try repository.create(item.name)) { error in
35+
XCTAssertEqual(error as? CustomRelayListError, CustomRelayListError.duplicateName)
36+
}
37+
}
38+
39+
func testAddingCustomList() throws {
40+
let name = "Netflix"
41+
42+
var item = try XCTUnwrap(repository.create(name))
43+
item.list.append(.country("SE"))
44+
item.list.append(.city("SE", "Gothenburg"))
45+
46+
repository.update(item)
47+
48+
let storedItem = repository.fetch(by: item.id)
49+
XCTAssertEqual(storedItem, item)
50+
}
51+
52+
func testDeletingCustomList() throws {
53+
let name = "Netflix"
54+
55+
var item = try XCTUnwrap(repository.create(name))
56+
item.list.append(.country("SE"))
57+
item.list.append(.city("SE", "Gothenburg"))
58+
repository.update(item)
59+
60+
let storedItem = repository.fetch(by: item.id)
61+
repository.delete(id: try XCTUnwrap(storedItem?.id))
62+
63+
XCTAssertNil(repository.fetch(by: item.id))
64+
}
65+
66+
func testFetchingAllCustomList() throws {
67+
let name = "Netflix"
68+
69+
var item = try XCTUnwrap(repository.create(name))
70+
item.list.append(.country("SE"))
71+
item.list.append(.city("SE", "Gothenburg"))
72+
repository.update(item)
73+
74+
XCTAssertEqual(repository.fetchAll().count, 1)
75+
}
76+
}

0 commit comments

Comments
 (0)