Skip to content

Commit 1a6cc86

Browse files
committed
Merge branch 'store-custom-lists-in-settings-ios-464'
2 parents 757e279 + e79f029 commit 1a6cc86

File tree

6 files changed

+256
-0
lines changed

6 files changed

+256
-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,103 @@
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+
NSLocalizedString(
21+
"DUPLICATE_CUSTOM_LIST_ERROR",
22+
tableName: "CustomListRepository",
23+
value: "Name is already taken.",
24+
comment: ""
25+
)
26+
}
27+
}
28+
}
29+
30+
public struct CustomListRepository: CustomListRepositoryProtocol {
31+
public var publisher: AnyPublisher<[CustomList], Never> {
32+
passthroughSubject.eraseToAnyPublisher()
33+
}
34+
35+
private let logger = Logger(label: "CustomListRepository")
36+
private let passthroughSubject = PassthroughSubject<[CustomList], Never>()
37+
38+
private let settingsParser: SettingsParser = {
39+
SettingsParser(decoder: JSONDecoder(), encoder: JSONEncoder())
40+
}()
41+
42+
public init() {}
43+
44+
public func create(_ name: String) throws -> CustomList {
45+
var lists = fetchAll()
46+
if lists.contains(where: { $0.name == name }) {
47+
throw CustomRelayListError.duplicateName
48+
} else {
49+
let item = CustomList(id: UUID(), name: name)
50+
lists.append(item)
51+
try write(lists)
52+
return item
53+
}
54+
}
55+
56+
public func delete(id: UUID) {
57+
do {
58+
var lists = fetchAll()
59+
if let index = lists.firstIndex(where: { $0.id == id }) {
60+
lists.remove(at: index)
61+
try write(lists)
62+
}
63+
} catch {
64+
logger.error(error: error)
65+
}
66+
}
67+
68+
public func fetch(by id: UUID) -> CustomList? {
69+
try? read().first(where: { $0.id == id })
70+
}
71+
72+
public func fetchAll() -> [CustomList] {
73+
(try? read()) ?? []
74+
}
75+
76+
public func update(_ list: CustomList) {
77+
do {
78+
var lists = fetchAll()
79+
if let index = lists.firstIndex(where: { $0.id == list.id }) {
80+
lists[index] = list
81+
try write(lists)
82+
}
83+
} catch {
84+
logger.error(error: error)
85+
}
86+
}
87+
}
88+
89+
extension CustomListRepository {
90+
private func read() throws -> [CustomList] {
91+
let data = try SettingsManager.store.read(key: .customRelayLists)
92+
93+
return try settingsParser.parseUnversionedPayload(as: [CustomList].self, from: data)
94+
}
95+
96+
private func write(_ list: [CustomList]) throws {
97+
let data = try settingsParser.produceUnversionedPayload(list)
98+
99+
try SettingsManager.store.write(data, for: .customRelayLists)
100+
101+
passthroughSubject.send(list)
102+
}
103+
}
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
@@ -785,6 +785,10 @@
785785
F03580252A13842C00E5DAFD /* IncreasedHitButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = F03580242A13842C00E5DAFD /* IncreasedHitButton.swift */; };
786786
F04F95A12B21D24400431E08 /* shadowsocks.h in Headers */ = {isa = PBXBuildFile; fileRef = F04F95A02B21D24400431E08 /* shadowsocks.h */; settings = {ATTRIBUTES = (Private, ); }; };
787787
F04FBE612A8379EE009278D7 /* AppPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = F04FBE602A8379EE009278D7 /* AppPreferences.swift */; };
788+
F050AE572B7376C6003F4EDB /* CustomListRepositoryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE552B7376C5003F4EDB /* CustomListRepositoryProtocol.swift */; };
789+
F050AE582B7376C6003F4EDB /* CustomListRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE562B7376C6003F4EDB /* CustomListRepository.swift */; };
790+
F050AE5A2B7376F4003F4EDB /* CustomList.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE592B7376F4003F4EDB /* CustomList.swift */; };
791+
F050AE5C2B73797D003F4EDB /* CustomListRepositoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE5B2B73797D003F4EDB /* CustomListRepositoryTests.swift */; };
788792
F05F39942B21C6C6006E60A7 /* relays.json in Resources */ = {isa = PBXBuildFile; fileRef = 58F3C0A524A50155003E76BE /* relays.json */; };
789793
F05F39972B21C735006E60A7 /* RelayCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5820675A26E6576800655B05 /* RelayCache.swift */; };
790794
F05F39982B21C73C006E60A7 /* CachedRelays.swift in Sources */ = {isa = PBXBuildFile; fileRef = 585DA87626B024A600B8C587 /* CachedRelays.swift */; };
@@ -1890,6 +1894,10 @@
18901894
F03580242A13842C00E5DAFD /* IncreasedHitButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncreasedHitButton.swift; sourceTree = "<group>"; };
18911895
F04F95A02B21D24400431E08 /* shadowsocks.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = shadowsocks.h; sourceTree = "<group>"; };
18921896
F04FBE602A8379EE009278D7 /* AppPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPreferences.swift; sourceTree = "<group>"; };
1897+
F050AE552B7376C5003F4EDB /* CustomListRepositoryProtocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomListRepositoryProtocol.swift; sourceTree = "<group>"; };
1898+
F050AE562B7376C6003F4EDB /* CustomListRepository.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomListRepository.swift; sourceTree = "<group>"; };
1899+
F050AE592B7376F4003F4EDB /* CustomList.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomList.swift; sourceTree = "<group>"; };
1900+
F050AE5B2B73797D003F4EDB /* CustomListRepositoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomListRepositoryTests.swift; sourceTree = "<group>"; };
18931901
F06045E52B231EB700B2D37A /* URLSessionTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionTransport.swift; sourceTree = "<group>"; };
18941902
F06045E92B23217E00B2D37A /* ShadowsocksTransport.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShadowsocksTransport.swift; sourceTree = "<group>"; };
18951903
F06045EB2B2322A500B2D37A /* Jittered.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Jittered.swift; sourceTree = "<group>"; };
@@ -2809,6 +2817,7 @@
28092817
A900E9BD2ACC654100C95F67 /* APIProxy+Stubs.swift */,
28102818
A9EC20E72A5D3A8C0040D56E /* CoordinatesTests.swift */,
28112819
5896AE85246D6AD8005B36CB /* CustomDateComponentsFormattingTests.swift */,
2820+
F050AE5B2B73797D003F4EDB /* CustomListRepositoryTests.swift */,
28122821
58915D622A25F8400066445B /* DeviceCheckOperationTests.swift */,
28132822
A900E9BB2ACC609200C95F67 /* DevicesProxy+Stubs.swift */,
28142823
58FBFBF0291630700020E046 /* DurationTests.swift */,
@@ -2878,6 +2887,9 @@
28782887
5827B0A02B0E064E00CCBBA1 /* AccessMethodRepository.swift */,
28792888
58EF875A2B16385400C098B2 /* AccessMethodRepositoryProtocol.swift */,
28802889
F0164EBB2B482E430020268D /* AppStorage.swift */,
2890+
F050AE592B7376F4003F4EDB /* CustomList.swift */,
2891+
F050AE562B7376C6003F4EDB /* CustomListRepository.swift */,
2892+
F050AE552B7376C5003F4EDB /* CustomListRepositoryProtocol.swift */,
28812893
A92ECC2B2A7803A50052F1B1 /* DeviceState.swift */,
28822894
580F8B8528197958002E0998 /* DNSSettings.swift */,
28832895
7A5869B22B5697AC00640D27 /* IPOverride.swift */,
@@ -4678,6 +4690,7 @@
46784690
F09D04B72AE941DA003D4F89 /* OutgoingConnectionProxyTests.swift in Sources */,
46794691
F09D04B92AE95111003D4F89 /* OutgoingConnectionProxy.swift in Sources */,
46804692
7A6000F92B6273A4001CF0D9 /* AccessMethodViewModel.swift in Sources */,
4693+
F050AE5C2B73797D003F4EDB /* CustomListRepositoryTests.swift in Sources */,
46814694
A9A5F9F62ACB05160083449F /* TunnelStatusNotificationProvider.swift in Sources */,
46824695
A9A5F9F72ACB05160083449F /* NotificationProviderProtocol.swift in Sources */,
46834696
A9A5F9F82ACB05160083449F /* NotificationProviderIdentifier.swift in Sources */,
@@ -4773,8 +4786,10 @@
47734786
isa = PBXSourcesBuildPhase;
47744787
buildActionMask = 2147483647;
47754788
files = (
4789+
F050AE582B7376C6003F4EDB /* CustomListRepository.swift in Sources */,
47764790
7A5869BD2B56EF7300640D27 /* IPOverride.swift in Sources */,
47774791
58B2FDEE2AA72098003EB5C6 /* ApplicationConfiguration.swift in Sources */,
4792+
F050AE572B7376C6003F4EDB /* CustomListRepositoryProtocol.swift in Sources */,
47784793
58B2FDE52AA71D5C003EB5C6 /* TunnelSettingsV2.swift in Sources */,
47794794
A97D30172AE6B5E90045C0E4 /* StoredWgKeyData.swift in Sources */,
47804795
F08827882B318F960020A383 /* PersistentAccessMethod.swift in Sources */,
@@ -4798,6 +4813,7 @@
47984813
F08827872B318C840020A383 /* ShadowsocksCipherOptions.swift in Sources */,
47994814
58B2FDE92AA71D5C003EB5C6 /* SettingsParser.swift in Sources */,
48004815
F08827892B3192110020A383 /* AccessMethodRepositoryProtocol.swift in Sources */,
4816+
F050AE5A2B7376F4003F4EDB /* CustomList.swift in Sources */,
48014817
58B2FDE22AA71D5C003EB5C6 /* StoredAccountData.swift in Sources */,
48024818
F0D7FF902B31E00B00E0FDE5 /* AccessMethodKind.swift in Sources */,
48034819
7A5869BC2B56EF3400640D27 /* IPOverrideRepository.swift in Sources */,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
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+
var streaming = try XCTUnwrap(repository.create("Netflix"))
68+
streaming.list.append(.country("FR"))
69+
streaming.list.append(.city("SE", "Gothenburg"))
70+
repository.update(streaming)
71+
72+
var gaming = try XCTUnwrap(repository.create("PS5"))
73+
gaming.list.append(.country("DE"))
74+
gaming.list.append(.city("SE", "Gothenburg"))
75+
repository.update(streaming)
76+
77+
XCTAssertEqual(repository.fetchAll().count, 2)
78+
}
79+
}

0 commit comments

Comments
 (0)