summaryrefslogtreecommitdiffhomepage
path: root/ios/MullvadSettings/AccessMethodRepository.swift
blob: 2523dc9d5d50a3e379592fad57369941022ca346 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
//
//  AccessMethodRepository.swift
//  MullvadVPN
//
//  Created by Jon Petersson on 12/12/2023.
//  Copyright © 2025 Mullvad VPN AB. All rights reserved.
//

import Combine
import Foundation
import MullvadLogging
import MullvadTypes

public class AccessMethodRepository: AccessMethodRepositoryProtocol, @unchecked Sendable {
    public static let directId = UUID(uuidString: "C9DB7457-2A55-42C3-A926-C07F82131994")!
    public static let bridgeId = UUID(uuidString: "8586E75A-CA7B-4432-B70D-EE65F3F95084")!
    public static let encryptedDNSId = UUID(uuidString: "831CB1F8-1829-42DD-B9DC-82902F298EC0")!

    private let logger = Logger(label: "AccessMethodRepository")

    // The access method names will be localised on creation time. As they are persisted
    // to on-device storage, they will not be relocalised if the user changes language.
    private let direct = PersistentAccessMethod(
        id: AccessMethodRepository.directId,
        name: "Direct",
        isEnabled: true,
        proxyConfiguration: .direct
    )

    private let bridge = PersistentAccessMethod(
        id: AccessMethodRepository.bridgeId,
        name: "Mullvad bridges",
        isEnabled: true,
        proxyConfiguration: .bridges
    )

    private let encryptedDNS = PersistentAccessMethod(
        id: AccessMethodRepository.encryptedDNSId,
        name: "Encrypted DNS proxy",
        isEnabled: true,
        proxyConfiguration: .encryptedDNS
    )

    private let accessMethodsSubject: CurrentValueSubject<[PersistentAccessMethod], Never>
    public var accessMethodsPublisher: AnyPublisher<[PersistentAccessMethod], Never> {
        accessMethodsSubject.eraseToAnyPublisher()
    }

    private let requestAccessMethodSubject: PassthroughSubject<PersistentAccessMethod, Never>
    public var requestAccessMethodPublisher: AnyPublisher<PersistentAccessMethod, Never> {
        requestAccessMethodSubject.eraseToAnyPublisher()
    }

    private let currentAccessMethodSubject: CurrentValueSubject<PersistentAccessMethod, Never>
    public var currentAccessMethodPublisher: AnyPublisher<PersistentAccessMethod, Never> {
        currentAccessMethodSubject.eraseToAnyPublisher()
    }

    public var directAccess: PersistentAccessMethod {
        direct
    }

    private var cancellables: Set<Combine.AnyCancellable> = []

    public init() {
        accessMethodsSubject = CurrentValueSubject([])
        requestAccessMethodSubject = PassthroughSubject()
        currentAccessMethodSubject = CurrentValueSubject(direct)

        addDefaultsMethods()

        let lastReachable = fetchLastReachable()
        accessMethodsSubject.send(fetchAll())
        requestAccessMethodSubject.send(lastReachable)
        // Set the correct access method, as opposed to the default value "direct" above.
        currentAccessMethodSubject.value = lastReachable

        currentAccessMethodPublisher
            .removeDuplicates()
            .sink { [weak self] currentAccessMethod in
                self?.saveCurrentAccessMethod(currentAccessMethod)
            }.store(in: &cancellables)
    }

    public func save(_ method: PersistentAccessMethod, notifyingAPI: Bool = false) {
        var methodStore = readApiAccessMethodStore()

        var method = method
        method.name = method.name.trimmingCharacters(in: .whitespaces)

        if let index = methodStore.accessMethods.firstIndex(where: { $0.id == method.id }) {
            methodStore.accessMethods[index] = method
        } else {
            methodStore.accessMethods.append(method)
        }

        do {
            try writeApiAccessMethodStore(methodStore)
            if notifyingAPI {
                accessMethodsSubject.send(methodStore.accessMethods)
            }
        } catch {
            logger.error("Could not save access method: \(method) \nError: \(error)")
        }
    }

    public func requestAccessMethod(_ method: PersistentAccessMethod) {
        requestAccessMethodSubject.send(method)
    }

    private func saveCurrentAccessMethod(_ method: PersistentAccessMethod) {
        var methodStore = readApiAccessMethodStore()
        methodStore.lastReachableAccessMethod = method

        do {
            try writeApiAccessMethodStore(methodStore)
        } catch {
            logger.error("Could not save last reachable access method: \(method) \nError: \(error)")
        }
    }

    public func delete(id: UUID) {
        var methodStore = readApiAccessMethodStore()
        guard let index = methodStore.accessMethods.firstIndex(where: { $0.id == id }) else { return }

        // Prevent removing methods that have static UUIDs and are always present.
        let method = methodStore.accessMethods[index]
        if !method.kind.isPermanent {
            methodStore.accessMethods.remove(at: index)
        }

        do {
            try writeApiAccessMethodStore(methodStore)
            accessMethodsSubject.send(methodStore.accessMethods)
        } catch {
            logger.error("Could not delete access method with id: \(id) \nError: \(error)")
        }
    }

    public func fetch(by id: UUID) -> PersistentAccessMethod? {
        fetchAll().first { $0.id == id }
    }

    public func fetchAll() -> [PersistentAccessMethod] {
        readApiAccessMethodStore().accessMethods
    }

    public func fetchLastReachable() -> PersistentAccessMethod {
        readApiAccessMethodStore().lastReachableAccessMethod
    }

    public func addDefaultsMethods() {
        add([
            direct,
            bridge,
            encryptedDNS,
        ])
    }

    private func add(_ methods: [PersistentAccessMethod]) {
        var methodStore = readApiAccessMethodStore()

        methods.forEach { method in
            if !methodStore.accessMethods.contains(where: { $0.id == method.id }) {
                methodStore.accessMethods.append(method)
            }
        }

        do {
            try writeApiAccessMethodStore(methodStore)
            accessMethodsSubject.send(methods)
        } catch {
            logger.error("Could not update access methods: \(methods) \nError: \(error)")
        }
    }

    private func readApiAccessMethodStore() -> PersistentAccessMethodStore {
        let parser = makeParser()

        do {
            let data = try SettingsManager.store.read(key: .apiAccessMethods)
            return try parser.parseUnversionedPayload(as: PersistentAccessMethodStore.self, from: data)
        } catch {
            logger.error("Could not load access method store: \(error)")
            return PersistentAccessMethodStore(lastReachableAccessMethod: direct, accessMethods: [])
        }
    }

    private func writeApiAccessMethodStore(_ store: PersistentAccessMethodStore) throws {
        let parser = makeParser()
        let data = try parser.produceUnversionedPayload(store)

        try SettingsManager.store.write(data, for: .apiAccessMethods)
    }

    private func makeParser() -> SettingsParser {
        SettingsParser(decoder: JSONDecoder(), encoder: JSONEncoder())
    }
}

extension AccessMethodRepository: MullvadAccessMethodChangeListening {
    public func accessMethodChangedTo(_ uuid: UUID) {
        guard let method = accessMethodsSubject.value.first(where: { $0.id == uuid }) else {
            logger.warning("Change reported to method with unknown ID: \(uuid)")
            return
        }

        Task {
            logger.debug("Mullvad API changed access method to \(method.name)")
            currentAccessMethodSubject.send(method)
        }
    }
}