diff --git a/ios/MullvadREST/Transport/AccessMethodIterator.swift b/ios/MullvadREST/Transport/AccessMethodIterator.swift new file mode 100644 index 000000000000..3251c7a96a8f --- /dev/null +++ b/ios/MullvadREST/Transport/AccessMethodIterator.swift @@ -0,0 +1,75 @@ +// +// AccessMethodIterator.swift +// MullvadREST +// +// Created by Mojgan on 2024-01-10. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Combine +import Foundation +import MullvadSettings + +class AccessMethodIterator { + private var lastReachableApiAccessCache: LastReachableApiAccessCache + private let dataSource: AccessMethodRepositoryDataSource + + private var cancellables = Set() + + /// `UserDefaults` key shared by both processes. Used to cache and synchronize last reachable api access method between them. + private let lastReachableConfigurationCacheKey = "LastReachableConfigurationCacheKey" + + private var index = 0 + private var enabledConfigurations: [PersistentAccessMethod] = [] + + private var lastReachableApiAccessId: UUID { + lastReachableApiAccessCache.id + } + + init(_ userDefaults: UserDefaults, dataSource: AccessMethodRepositoryDataSource) { + self.dataSource = dataSource + self.lastReachableApiAccessCache = LastReachableApiAccessCache( + key: lastReachableConfigurationCacheKey, + defaultValue: dataSource.directAccess.id, + container: userDefaults + ) + + self.dataSource + .publisher + .sink { [weak self] configurations in + guard let self else { return } + self.enabledConfigurations = configurations.filter { $0.isEnabled } + self.refreshCacheIfNeeded() + } + .store(in: &cancellables) + } + + var current: PersistentAccessMethod { + if enabledConfigurations.isEmpty { + /// Returning `Default` strategy when all is disabled + return dataSource.directAccess + } else { + /// Picking the next `Enabled` configuration in order they are added + /// And starting from the beginning when it reaches end + let circularIndex = index % enabledConfigurations.count + return enabledConfigurations[circularIndex] + } + } + + private func refreshCacheIfNeeded() { + /// Validating the index of `lastReachableApiAccessCache` after any changes in `AccessMethodRepository` + if let idx = enabledConfigurations.firstIndex(where: { $0.id == self.lastReachableApiAccessId }) { + index = idx + } else { + /// When `idx` is `nil`, that means the current configuration is not valid any more + /// Invalidating cache by replacing the `current` to the next enabled access method + lastReachableApiAccessCache.id = current.id + } + } + + func next() { + let (partial, isOverflow) = index.addingReportingOverflow(1) + index = isOverflow ? 0 : partial + lastReachableApiAccessCache.id = current.id + } +} diff --git a/ios/MullvadREST/Transport/LastReachableApiAccessCache.swift b/ios/MullvadREST/Transport/LastReachableApiAccessCache.swift new file mode 100644 index 000000000000..7b820d441b56 --- /dev/null +++ b/ios/MullvadREST/Transport/LastReachableApiAccessCache.swift @@ -0,0 +1,32 @@ +// +// LastReachableApiAccessStorage.swift +// MullvadREST +// +// Created by Mojgan on 2024-01-08. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import MullvadSettings + +struct LastReachableApiAccessCache: Identifiable { + private var appStorage: AppStorage + + init(key: String, defaultValue: UUID, container: UserDefaults) { + self.appStorage = AppStorage( + wrappedValue: defaultValue.uuidString, + key: key, + container: container + ) + } + + var id: UUID { + get { + let value = appStorage.wrappedValue + return UUID(uuidString: value)! + } + set { + appStorage.wrappedValue = newValue.uuidString + } + } +} diff --git a/ios/MullvadREST/Transport/Shadowsocks/ShadowsocksLoader.swift b/ios/MullvadREST/Transport/Shadowsocks/ShadowsocksLoader.swift new file mode 100644 index 000000000000..3d55f50d7ae3 --- /dev/null +++ b/ios/MullvadREST/Transport/Shadowsocks/ShadowsocksLoader.swift @@ -0,0 +1,79 @@ +// +// LocalShadowsocksLoader.swift +// MullvadREST +// +// Created by Mojgan on 2024-01-08. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import MullvadTypes + +public protocol ShadowsocksLoaderProtocol { + var configuration: ShadowsocksConfiguration { get throws } + func preloadNewConfiguration() throws +} + +public class ShadowsocksLoader: ShadowsocksLoaderProtocol { + private let shadowsocksCache: ShadowsocksConfigurationCache + private let relayCache: RelayCacheProtocol + private var relayConstraints = RelayConstraints() + private let constraintsUpdater: RelayConstraintsUpdater + + public init( + shadowsocksCache: ShadowsocksConfigurationCache, + relayCache: RelayCacheProtocol, + constraintsUpdater: RelayConstraintsUpdater + ) { + self.shadowsocksCache = shadowsocksCache + self.relayCache = relayCache + self.constraintsUpdater = constraintsUpdater + constraintsUpdater.onNewConstraints = { [weak self] newConstraints in + self?.relayConstraints = newConstraints + } + } + + public var configuration: ShadowsocksConfiguration { + get throws { + try load() + } + } + + public func preloadNewConfiguration() throws { + // because the previous shadowsocks configuration was invalid, therefore generate a new one. + let newConfiguration = try create() + try shadowsocksCache.write(newConfiguration) + } + + /// Returns the last used shadowsocks configuration, otherwise a new randomized configuration. + private func load() throws -> ShadowsocksConfiguration { + do { + // If a previous shadowsocks configuration was in cache, return it directly. + return try shadowsocksCache.read() + } catch { + // There is no previous configuration either if this is the first time this code ran + let newConfiguration = try create() + try shadowsocksCache.write(newConfiguration) + return newConfiguration + } + } + + /// Returns a randomly selected shadowsocks configuration. + private func create() throws -> ShadowsocksConfiguration { + let cachedRelays = try relayCache.read() + let bridgeConfiguration = RelaySelector.shadowsocksTCPBridge(from: cachedRelays.relays) + let closestRelay = RelaySelector.closestShadowsocksRelayConstrained( + by: relayConstraints, + in: cachedRelays.relays + ) + + guard let bridgeAddress = closestRelay?.ipv4AddrIn, let bridgeConfiguration else { throw POSIXError(.ENOENT) } + + return ShadowsocksConfiguration( + address: .ipv4(bridgeAddress), + port: bridgeConfiguration.port, + password: bridgeConfiguration.password, + cipher: bridgeConfiguration.cipher + ) + } +} diff --git a/ios/MullvadREST/Transport/TransportProvider.swift b/ios/MullvadREST/Transport/TransportProvider.swift index e88afd33dc6d..01b46130e5ac 100644 --- a/ios/MullvadREST/Transport/TransportProvider.swift +++ b/ios/MullvadREST/Transport/TransportProvider.swift @@ -12,38 +12,19 @@ import MullvadTypes public final class TransportProvider: RESTTransportProvider { private let urlSessionTransport: URLSessionTransport - private let relayCache: RelayCacheProtocol - private let logger = Logger(label: "TransportProvider") private let addressCache: REST.AddressCache - private let shadowsocksCache: ShadowsocksConfigurationCache private var transportStrategy: TransportStrategy - private var currentTransport: RESTTransport? private let parallelRequestsMutex = NSLock() - private var relayConstraints = RelayConstraints() - private let constraintsUpdater: RelayConstraintsUpdater public init( urlSessionTransport: URLSessionTransport, - relayCache: RelayCacheProtocol, addressCache: REST.AddressCache, - shadowsocksCache: ShadowsocksConfigurationCache, - transportStrategy: TransportStrategy, - constraintsUpdater: RelayConstraintsUpdater + transportStrategy: TransportStrategy ) { self.urlSessionTransport = urlSessionTransport - self.relayCache = relayCache self.addressCache = addressCache - self.shadowsocksCache = shadowsocksCache self.transportStrategy = transportStrategy - self.constraintsUpdater = constraintsUpdater - constraintsUpdater.onNewConstraints = { [weak self] newConstraints in - self?.parallelRequestsMutex.lock() - defer { - self?.parallelRequestsMutex.unlock() - } - self?.relayConstraints = newConstraints - } } public func makeTransport() -> RESTTransport? { @@ -59,78 +40,6 @@ public final class TransportProvider: RESTTransportProvider { } } - // MARK: - - - private func shadowsocks() -> RESTTransport? { - do { - let shadowsocksConfiguration = try shadowsocksConfiguration() - - let shadowsocksURLSession = urlSessionTransport.urlSession - let shadowsocksTransport = ShadowsocksTransport( - urlSession: shadowsocksURLSession, - configuration: shadowsocksConfiguration, - addressCache: addressCache - ) - return shadowsocksTransport - } catch { - logger.error(error: error, message: "Failed to produce shadowsocks configuration.") - return nil - } - } - - // TODO: Pass the socks5 username, password, and ip+port combo here. - private func socks5() -> RESTTransport? { - return URLSessionSocks5Transport( - urlSession: urlSessionTransport.urlSession, - configuration: Socks5Configuration(proxyEndpoint: AnyIPEndpoint.ipv4(IPv4Endpoint( - ip: .loopback, - port: 8889 - ))), - addressCache: addressCache - ) - } - - /// Returns the last used shadowsocks configuration, otherwise a new randomized configuration. - private func shadowsocksConfiguration() throws -> ShadowsocksConfiguration { - // If a previous shadowsocks configuration was in cache, return it directly. - do { - return try shadowsocksCache.read() - } catch { - // There is no previous configuration either if this is the first time this code ran - // Or because the previous shadowsocks configuration was invalid, therefore generate a new one. - return try makeNewShadowsocksConfiguration() - } - } - - /// Returns a randomly selected shadowsocks configuration. - private func makeNewShadowsocksConfiguration() throws -> ShadowsocksConfiguration { - let cachedRelays = try relayCache.read() - let bridgeConfiguration = RelaySelector.shadowsocksTCPBridge(from: cachedRelays.relays) - let closestRelay = RelaySelector.closestShadowsocksRelayConstrained( - by: relayConstraints, - in: cachedRelays.relays - ) - - guard let bridgeAddress = closestRelay?.ipv4AddrIn, let bridgeConfiguration else { throw POSIXError(.ENOENT) } - - let newConfiguration = ShadowsocksConfiguration( - address: .ipv4(bridgeAddress), - port: bridgeConfiguration.port, - password: bridgeConfiguration.password, - cipher: bridgeConfiguration.cipher - ) - - do { - try shadowsocksCache.write(newConfiguration) - } catch { - logger.error(error: error, message: "Failed to persist shadowsocks cache.") - } - - return newConfiguration - } - - // MARK: - - /// When several requests fail at the same time, prevents the `transportStrategy` from switching multiple times. /// /// The `strategy` is checked against the `transportStrategy`. When several requests are made and fail in parallel, @@ -153,15 +62,21 @@ public final class TransportProvider: RESTTransportProvider { /// /// - Returns: A `RESTTransport` object to make a connection private func makeTransportInner() -> RESTTransport? { - if currentTransport == nil { - switch transportStrategy.connectionTransport() { - case .useShadowsocks: - currentTransport = shadowsocks() - case .useURLSession: - currentTransport = urlSessionTransport - case .useSocks5: - currentTransport = socks5() - } + switch transportStrategy.connectionTransport() { + case .direct: + currentTransport = urlSessionTransport + case let .shadowsocks(configuration): + currentTransport = ShadowsocksTransport( + urlSession: urlSessionTransport.urlSession, + configuration: configuration, + addressCache: addressCache + ) + case let .socks5(configuration): + currentTransport = URLSessionSocks5Transport( + urlSession: urlSessionTransport.urlSession, + configuration: configuration, + addressCache: addressCache + ) } return currentTransport } diff --git a/ios/MullvadREST/Transport/TransportStrategy.swift b/ios/MullvadREST/Transport/TransportStrategy.swift index 27411d244e55..bb3404501fa8 100644 --- a/ios/MullvadREST/Transport/TransportStrategy.swift +++ b/ios/MullvadREST/Transport/TransportStrategy.swift @@ -7,56 +7,98 @@ // import Foundation +import Logging +import MullvadSettings +import MullvadTypes -public struct TransportStrategy: Equatable { +public class TransportStrategy: Equatable { /// The different transports suggested by the strategy public enum Transport { - /// Suggests using a direct connection - case useURLSession - /// Suggests connecting via Shadowsocks proxy - case useShadowsocks - /// Suggests connecting via socks proxy - case useSocks5 - } + /// Connecting a direct connection + case direct + + /// Connecting via shadowsocks proxy + case shadowsocks(configuration: ShadowsocksConfiguration) - /// The internal counter for suggested transports. - /// - /// A value of `0` means a direct transport suggestion, a value of `1` or `2` means a Shadowsocks transport - /// suggestion. - /// - /// `internal` instead of `private` for testing purposes. - internal var connectionAttempts: Int + /// Connecting via socks proxy + case socks5(configuration: Socks5Configuration) + } - /// Enables recording of failed connection attempts. - private let userDefaults: UserDefaults + private let shadowsocksLoader: ShadowsocksLoaderProtocol - /// `UserDefaults` key shared by both processes. Used to cache and synchronize connection attempts between them. - internal static let connectionAttemptsSharedCacheKey = "ConnectionAttemptsSharedCacheKey" + private let accessMethodIterator: AccessMethodIterator - public init(_ userDefaults: UserDefaults) { - self.connectionAttempts = userDefaults.integer(forKey: Self.connectionAttemptsSharedCacheKey) - self.userDefaults = userDefaults + public init( + _ userDefaults: UserDefaults, + datasource: AccessMethodRepositoryDataSource, + shadowsocksLoader: ShadowsocksLoaderProtocol + ) { + self.shadowsocksLoader = shadowsocksLoader + self.accessMethodIterator = AccessMethodIterator( + userDefaults, + dataSource: datasource + ) } - /// Instructs the strategy that a network connection failed - /// - /// Every third failure results in a direct transport suggestion. - public mutating func didFail() { - let (partial, isOverflow) = connectionAttempts.addingReportingOverflow(1) - // (Int.max - 1) is a multiple of 3, go directly to 2 when overflowing - // to keep the "every third failure" algorithm correct - connectionAttempts = isOverflow ? 2 : partial - userDefaults.set(connectionAttempts, forKey: Self.connectionAttemptsSharedCacheKey) + /// Rotating between enabled configurations by what order they were added in + public func didFail() { + let configuration = accessMethodIterator.current + switch configuration.kind { + case .bridges: + try? shadowsocksLoader.preloadNewConfiguration() + fallthrough + default: + self.accessMethodIterator.next() + } } /// The suggested connection transport - /// - /// - Returns: `.useURLSession` for every 3rd failed attempt, `.useShadowsocks` otherwise public func connectionTransport() -> Transport { - connectionAttempts.isMultiple(of: 3) ? .useURLSession : .useShadowsocks + let configuration = accessMethodIterator.current + switch configuration.proxyConfiguration { + case .direct: + return .direct + case .bridges: + do { + let configuration = try shadowsocksLoader.configuration + return .shadowsocks(configuration: configuration) + } catch { + didFail() + return connectionTransport() + } + case let .shadowsocks(configuration): + return .shadowsocks(configuration: ShadowsocksConfiguration( + address: configuration.server, + port: configuration.port, + password: configuration.password, + cipher: configuration.cipher.rawValue.description + )) + case let .socks5(configuration): + switch configuration.authentication { + case .noAuthentication: + return .socks5(configuration: Socks5Configuration(proxyEndpoint: configuration.toAnyIPEndpoint)) + case let .usernamePassword(username, password): + return .socks5(configuration: Socks5Configuration( + proxyEndpoint: configuration.toAnyIPEndpoint, + username: username, + password: password + )) + } + } } public static func == (lhs: TransportStrategy, rhs: TransportStrategy) -> Bool { - lhs.connectionAttempts == rhs.connectionAttempts + lhs.accessMethodIterator.current == rhs.accessMethodIterator.current + } +} + +private extension PersistentProxyConfiguration.SocksConfiguration { + var toAnyIPEndpoint: AnyIPEndpoint { + switch server { + case let .ipv4(ip): + return .ipv4(IPv4Endpoint(ip: ip, port: port)) + case let .ipv6(ip): + return .ipv6(IPv6Endpoint(ip: ip, port: port)) + } } } diff --git a/ios/MullvadRESTTests/AccessMethodIteratorTests.swift b/ios/MullvadRESTTests/AccessMethodIteratorTests.swift new file mode 100644 index 000000000000..8e8d004e3427 --- /dev/null +++ b/ios/MullvadRESTTests/AccessMethodIteratorTests.swift @@ -0,0 +1,27 @@ +// +// AccessMethodIteratorTests.swift +// MullvadRESTTests +// +// Created by Mojgan on 2024-01-10. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +@testable import MullvadREST +@testable import MullvadSettings +@testable import MullvadTypes +import XCTest + +final class AccessMethodIteratorTests: XCTestCase { + var userDefaults: UserDefaults! + static var suiteName: String! + + override class func setUp() { + super.setUp() + suiteName = UUID().uuidString + } + + override func tearDownWithError() throws { + userDefaults.removePersistentDomain(forName: Self.suiteName) + try super.tearDownWithError() + } +} diff --git a/ios/MullvadRESTTests/AccessMethodRepositoryStub.swift b/ios/MullvadRESTTests/AccessMethodRepositoryStub.swift new file mode 100644 index 000000000000..2e006dd52467 --- /dev/null +++ b/ios/MullvadRESTTests/AccessMethodRepositoryStub.swift @@ -0,0 +1,26 @@ +// +// AccessMethodRepositoryStub.swift +// MullvadRESTTests +// +// Created by Mojgan on 2024-01-02. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Combine +import MullvadSettings + +typealias PersistentAccessMethod = MullvadSettings.PersistentAccessMethod +class AccessMethodRepositoryStub: AccessMethodRepositoryDataSource { + var directAccess: MullvadSettings.PersistentAccessMethod + + var publisher: AnyPublisher<[MullvadSettings.PersistentAccessMethod], Never> { + passthroughSubject.eraseToAnyPublisher() + } + + let passthroughSubject: CurrentValueSubject<[PersistentAccessMethod], Never> = CurrentValueSubject([]) + + init(accessMethods: [PersistentAccessMethod]) { + directAccess = accessMethods.first(where: { $0.kind == .direct })! + passthroughSubject.send(accessMethods) + } +} diff --git a/ios/MullvadRESTTests/ShadowsocksLoaderStub.swift b/ios/MullvadRESTTests/ShadowsocksLoaderStub.swift new file mode 100644 index 000000000000..288cd0b66e7f --- /dev/null +++ b/ios/MullvadRESTTests/ShadowsocksLoaderStub.swift @@ -0,0 +1,25 @@ +// +// ShadowsocksLoaderStub.swift +// MullvadRESTTests +// +// Created by Mojgan on 2024-01-08. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Foundation +@testable import MullvadREST +import MullvadSettings +import MullvadTypes + +struct ShadowsocksLoaderStub: ShadowsocksLoaderProtocol { + private let _configuration: ShadowsocksConfiguration + init(configuration: ShadowsocksConfiguration) { + _configuration = configuration + } + + var configuration: ShadowsocksConfiguration { + _configuration + } + + func preloadNewConfiguration() throws {} +} diff --git a/ios/MullvadRESTTests/TransportStrategyTests.swift b/ios/MullvadRESTTests/TransportStrategyTests.swift index 420f44f2ff4f..70fba3822a97 100644 --- a/ios/MullvadRESTTests/TransportStrategyTests.swift +++ b/ios/MullvadRESTTests/TransportStrategyTests.swift @@ -7,6 +7,7 @@ // @testable import MullvadREST +@testable import MullvadSettings @testable import MullvadTypes import XCTest @@ -14,6 +15,11 @@ final class TransportStrategyTests: XCTestCase { var userDefaults: UserDefaults! static var suiteName: String! + private var directAccess: PersistentAccessMethod! + private var bridgeAccess: PersistentAccessMethod! + + private var shadowsocksLoader: ShadowsocksLoaderProtocol! + override class func setUp() { super.setUp() suiteName = UUID().uuidString @@ -22,6 +28,27 @@ final class TransportStrategyTests: XCTestCase { override func setUpWithError() throws { try super.setUpWithError() userDefaults = UserDefaults(suiteName: Self.suiteName) + + shadowsocksLoader = ShadowsocksLoaderStub(configuration: ShadowsocksConfiguration( + address: .ipv4(.loopback), + port: 1080, + password: "123", + cipher: CipherIdentifiers.CHACHA20.description + )) + + directAccess = PersistentAccessMethod( + id: UUID(uuidString: "C9DB7457-2A55-42C3-A926-C07F82131994")!, + name: "", + isEnabled: true, + proxyConfiguration: .direct + ) + + bridgeAccess = PersistentAccessMethod( + id: UUID(uuidString: "8586E75A-CA7B-4432-B70D-EE65F3F95084")!, + name: "", + isEnabled: true, + proxyConfiguration: .bridges + ) } override func tearDownWithError() throws { @@ -29,35 +56,120 @@ final class TransportStrategyTests: XCTestCase { try super.tearDownWithError() } - func testEveryThirdConnectionAttemptsIsDirect() { - loopStrategyTest(with: TransportStrategy(userDefaults), in: 0 ... 12) + func testUseDefaultStrategyWhenAllIsDisabled() throws { + directAccess.isEnabled = false + bridgeAccess.isEnabled = false + let transportStrategy = TransportStrategy( + userDefaults, + datasource: AccessMethodRepositoryStub(accessMethods: [ + directAccess, + bridgeAccess, + ]), + shadowsocksLoader: shadowsocksLoader + ) + for _ in 0 ... 4 { + transportStrategy.didFail() + XCTAssertEqual(transportStrategy.connectionTransport(), .direct) + } } - func testOverflowingConnectionAttempts() { - userDefaults.set(Int.max, forKey: TransportStrategy.connectionAttemptsSharedCacheKey) - let strategy = TransportStrategy(userDefaults) + func testReuseSameStrategyWhenEverythingElseIsDisabled() throws { + directAccess.isEnabled = false + let transportStrategy = TransportStrategy( + userDefaults, + datasource: AccessMethodRepositoryStub(accessMethods: [ + directAccess, + bridgeAccess, + ]), + shadowsocksLoader: shadowsocksLoader + ) - // (Int.max - 1) is a multiple of 3, so skip the first iteration - loopStrategyTest(with: strategy, in: 1 ... 12) - } + for _ in 0 ... 10 { + transportStrategy.didFail() - func testConnectionAttemptsAreRecordedAfterFailure() { - var strategy = TransportStrategy(userDefaults) + XCTAssertEqual( + transportStrategy.connectionTransport(), + .shadowsocks(configuration: try XCTUnwrap(shadowsocksLoader.configuration)) + ) + } + } - strategy.didFail() + func testLoopsFromTheStartAfterTryingAllEnabledStrategies() { + bridgeAccess.isEnabled = false + let transportStrategy = TransportStrategy( + userDefaults, + datasource: AccessMethodRepositoryStub(accessMethods: [ + directAccess, + bridgeAccess, + PersistentAccessMethod( + id: UUID(uuidString: "8586E75A-CA7B-4432-B70D-EE65F3F95090")!, + name: "", + isEnabled: true, + proxyConfiguration: .shadowsocks(PersistentProxyConfiguration.ShadowsocksConfiguration( + server: .ipv4(.loopback), + port: 8083, + password: "", + cipher: .default + )) + ), + ]), + shadowsocksLoader: shadowsocksLoader + ) - let recordedValue = userDefaults.integer(forKey: TransportStrategy.connectionAttemptsSharedCacheKey) - XCTAssertEqual(1, recordedValue) + for _ in 0 ..< 6 { + transportStrategy.didFail() + } + XCTAssertEqual(transportStrategy.connectionTransport(), .direct) } - private func loopStrategyTest(with strategy: TransportStrategy, in range: ClosedRange) { - var strategy = strategy + func testContinuesToUseNextWhenItIsNotReachable() { + bridgeAccess.isEnabled = false + let transportStrategy = TransportStrategy( + userDefaults, + datasource: AccessMethodRepositoryStub(accessMethods: [ + directAccess, + bridgeAccess, + PersistentAccessMethod( + id: UUID(uuidString: "8586E75A-CA7B-4432-B70D-EE65F3F95090")!, + name: "", + isEnabled: true, + proxyConfiguration: .shadowsocks(PersistentProxyConfiguration.ShadowsocksConfiguration( + server: .ipv4(.loopback), + port: 8083, + password: "", + cipher: .default + )) + ), + ]), + shadowsocksLoader: shadowsocksLoader + ) + XCTAssertEqual(transportStrategy.connectionTransport(), .direct) + transportStrategy.didFail() + XCTAssertEqual( + transportStrategy.connectionTransport(), + .shadowsocks(configuration: ShadowsocksConfiguration( + address: .ipv4(.loopback), + port: 8083, + password: "", + cipher: ShadowsocksCipherOptions.default.rawValue.description + )) + ) + } +} - for index in range { - let expectedResult: TransportStrategy.Transport - expectedResult = index.isMultiple(of: 3) ? .useURLSession : .useShadowsocks - XCTAssertEqual(strategy.connectionTransport(), expectedResult) - strategy.didFail() +extension TransportStrategy.Transport: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + switch (lhs, rhs) { + case(.direct, .direct): + return true + case let (.shadowsocks(config1), .shadowsocks(config2)): + return config1.port == config2.port && config1.cipher == config2.cipher && config1.password == config2 + .password + case let (.socks5(config1), .socks5(config2)): + return config1.proxyEndpoint == config2.proxyEndpoint && config1.username == config2.username && config1 + .password == config2.password + default: + return false } } } diff --git a/ios/MullvadSettings/AccessMethodRepository.swift b/ios/MullvadSettings/AccessMethodRepository.swift index 158501cd7d85..7de5ba26b4e9 100644 --- a/ios/MullvadSettings/AccessMethodRepository.swift +++ b/ios/MullvadSettings/AccessMethodRepository.swift @@ -29,13 +29,7 @@ public class AccessMethodRepository: AccessMethodRepositoryProtocol { passthroughSubject.eraseToAnyPublisher() } - public var accessMethods: [PersistentAccessMethod] { - passthroughSubject.value - } - - public static let shared = AccessMethodRepository() - - private init() { + public init() { add(passthroughSubject.value) } @@ -115,4 +109,8 @@ public class AccessMethodRepository: AccessMethodRepositoryProtocol { private func makeParser() -> SettingsParser { SettingsParser(decoder: JSONDecoder(), encoder: JSONEncoder()) } + + public var directAccess: PersistentAccessMethod { + passthroughSubject.value.first(where: { $0.kind == .direct })! + } } diff --git a/ios/MullvadSettings/AccessMethodRepositoryProtocol.swift b/ios/MullvadSettings/AccessMethodRepositoryProtocol.swift index 87fbb00c8928..7c25b28649bf 100644 --- a/ios/MullvadSettings/AccessMethodRepositoryProtocol.swift +++ b/ios/MullvadSettings/AccessMethodRepositoryProtocol.swift @@ -12,6 +12,8 @@ import Foundation public protocol AccessMethodRepositoryDataSource { /// Publisher that propagates a snapshot of persistent store upon modifications. var publisher: AnyPublisher<[PersistentAccessMethod], Never> { get } + + var directAccess: PersistentAccessMethod { get } } public protocol AccessMethodRepositoryProtocol: AccessMethodRepositoryDataSource { diff --git a/ios/MullvadSettings/AppStorage.swift b/ios/MullvadSettings/AppStorage.swift new file mode 100644 index 000000000000..9fd9523ac75a --- /dev/null +++ b/ios/MullvadSettings/AppStorage.swift @@ -0,0 +1,43 @@ +// +// AppStorage.swift +// MullvadSettings +// +// Created by Mojgan on 2024-01-05. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Foundation +@propertyWrapper +public struct AppStorage { + let key: String + let defaultValue: Value + let container: UserDefaults + + public var wrappedValue: Value { + get { + container.value(forKey: key) as? Value ?? defaultValue + } + set { + if let anyOptional = newValue as? AnyOptional, + anyOptional.isNil { + container.removeObject(forKey: key) + } else { + container.set(newValue, forKey: key) + } + } + } + + public init(wrappedValue: Value, key: String, container: UserDefaults) { + self.defaultValue = wrappedValue + self.container = container + self.key = key + } +} + +protocol AnyOptional { + var isNil: Bool { get } +} + +extension Optional: AnyOptional { + var isNil: Bool { self == nil } +} diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index 60a74fa1b5ef..04ed0d83e7eb 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -723,6 +723,13 @@ E1187ABD289BBB850024E748 /* OutOfTimeContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1187ABB289BBB850024E748 /* OutOfTimeContentView.swift */; }; E158B360285381C60002F069 /* String+AccountFormatting.swift in Sources */ = {isa = PBXBuildFile; fileRef = E158B35F285381C60002F069 /* String+AccountFormatting.swift */; }; E1FD0DF528AA7CE400299DB4 /* StatusActivityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FD0DF428AA7CE400299DB4 /* StatusActivityView.swift */; }; + F0164EBA2B4456D30020268D /* AccessMethodRepositoryStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0164EB92B4456D30020268D /* AccessMethodRepositoryStub.swift */; }; + F0164EBC2B482E430020268D /* AppStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0164EBB2B482E430020268D /* AppStorage.swift */; }; + F0164EBE2B4BFF940020268D /* ShadowsocksLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0164EBD2B4BFF940020268D /* ShadowsocksLoader.swift */; }; + F0164EC12B4C03980020268D /* LastReachableApiAccessCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0164EC02B4C03980020268D /* LastReachableApiAccessCache.swift */; }; + F0164EC32B4C49D30020268D /* ShadowsocksLoaderStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0164EC22B4C49D30020268D /* ShadowsocksLoaderStub.swift */; }; + F0164ED12B4F2DCB0020268D /* AccessMethodIterator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0164ED02B4F2DCB0020268D /* AccessMethodIterator.swift */; }; + F0164ED32B4F2F3E0020268D /* AccessMethodIteratorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0164ED22B4F2F3E0020268D /* AccessMethodIteratorTests.swift */; }; F028A56A2A34D4E700C0CAA3 /* RedeemVoucherViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F028A5692A34D4E700C0CAA3 /* RedeemVoucherViewController.swift */; }; F028A56C2A34D8E600C0CAA3 /* AddCreditSucceededViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F028A56B2A34D8E600C0CAA3 /* AddCreditSucceededViewController.swift */; }; F03580252A13842C00E5DAFD /* IncreasedHitButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = F03580242A13842C00E5DAFD /* IncreasedHitButton.swift */; }; @@ -1760,6 +1767,13 @@ E1187ABB289BBB850024E748 /* OutOfTimeContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OutOfTimeContentView.swift; sourceTree = ""; }; E158B35F285381C60002F069 /* String+AccountFormatting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+AccountFormatting.swift"; sourceTree = ""; }; E1FD0DF428AA7CE400299DB4 /* StatusActivityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusActivityView.swift; sourceTree = ""; }; + F0164EB92B4456D30020268D /* AccessMethodRepositoryStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessMethodRepositoryStub.swift; sourceTree = ""; }; + F0164EBB2B482E430020268D /* AppStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStorage.swift; sourceTree = ""; }; + F0164EBD2B4BFF940020268D /* ShadowsocksLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShadowsocksLoader.swift; sourceTree = ""; }; + F0164EC02B4C03980020268D /* LastReachableApiAccessCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LastReachableApiAccessCache.swift; sourceTree = ""; }; + F0164EC22B4C49D30020268D /* ShadowsocksLoaderStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShadowsocksLoaderStub.swift; sourceTree = ""; }; + F0164ED02B4F2DCB0020268D /* AccessMethodIterator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessMethodIterator.swift; sourceTree = ""; }; + F0164ED22B4F2F3E0020268D /* AccessMethodIteratorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessMethodIteratorTests.swift; sourceTree = ""; }; F028A5692A34D4E700C0CAA3 /* RedeemVoucherViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RedeemVoucherViewController.swift; sourceTree = ""; }; F028A56B2A34D8E600C0CAA3 /* AddCreditSucceededViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddCreditSucceededViewController.swift; sourceTree = ""; }; F03580242A13842C00E5DAFD /* IncreasedHitButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncreasedHitButton.swift; sourceTree = ""; }; @@ -2749,6 +2763,7 @@ 588D7ED72AF3A533005DF40A /* AccessMethodKind.swift */, 5827B0A02B0E064E00CCBBA1 /* AccessMethodRepository.swift */, 58EF875A2B16385400C098B2 /* AccessMethodRepositoryProtocol.swift */, + F0164EBB2B482E430020268D /* AppStorage.swift */, A92ECC2B2A7803A50052F1B1 /* DeviceState.swift */, 580F8B8528197958002E0998 /* DNSSettings.swift */, 06410DFD292CE18F00AFC18C /* KeychainSettingsStore.swift */, @@ -2776,8 +2791,8 @@ isa = PBXGroup; children = ( 58BDEB982A98F4ED00F578F2 /* AnyTransport.swift */, - 58BDEB9A2A98F58600F578F2 /* TimeServerProxy.swift */, 58BDEB9C2A98F69E00F578F2 /* MemoryCache.swift */, + 58BDEB9A2A98F58600F578F2 /* TimeServerProxy.swift */, ); path = Mocks; sourceTree = ""; @@ -3184,10 +3199,13 @@ 58FBFBE7291622580020E046 /* MullvadRESTTests */ = { isa = PBXGroup; children = ( + F0164ED22B4F2F3E0020268D /* AccessMethodIteratorTests.swift */, + F0164EB92B4456D30020268D /* AccessMethodRepositoryStub.swift */, 58FBFBE8291622580020E046 /* ExponentialBackoffTests.swift */, - A917352029FAAA5200D5DCFD /* TransportStrategyTests.swift */, - 58B4656F2A98C53300467203 /* RequestExecutorTests.swift */, 58BDEB9E2A98F6B400F578F2 /* Mocks */, + 58B4656F2A98C53300467203 /* RequestExecutorTests.swift */, + F0164EC22B4C49D30020268D /* ShadowsocksLoaderStub.swift */, + A917352029FAAA5200D5DCFD /* TransportStrategyTests.swift */, ); path = MullvadRESTTests; sourceTree = ""; @@ -3357,7 +3375,9 @@ F0DC77A02B2223290087F09D /* Transport */ = { isa = PBXGroup; children = ( + F0164ED02B4F2DCB0020268D /* AccessMethodIterator.swift */, F0DC77A32B2315800087F09D /* Direct */, + F0164EC02B4C03980020268D /* LastReachableApiAccessCache.swift */, 06FAE67D28F83CA50033DD93 /* RESTTransport.swift */, 58E7BA182A975DF70068EC3A /* RESTTransportProvider.swift */, F0DC77A22B2314EF0087F09D /* Shadowsocks */, @@ -3384,6 +3404,7 @@ F04F95A02B21D24400431E08 /* shadowsocks.h */, F0DDE4132B220458006B57A7 /* ShadowsocksConfiguration.swift */, F0DDE4102B220458006B57A7 /* ShadowsocksConfigurationCache.swift */, + F0164EBD2B4BFF940020268D /* ShadowsocksLoader.swift */, F0DDE40F2B220458006B57A7 /* ShadowSocksProxy.swift */, F06045E92B23217E00B2D37A /* ShadowsocksTransport.swift */, ); @@ -4260,6 +4281,7 @@ 06799AE728F98E4800ACD94E /* RESTURLSession.swift in Sources */, A90763B52B2857D50045ADF0 /* Socks5Constants.swift in Sources */, A90763BA2B2857D50045ADF0 /* Socks5Error.swift in Sources */, + F0164EC12B4C03980020268D /* LastReachableApiAccessCache.swift in Sources */, 06799AF428F98E4800ACD94E /* RESTAuthorization.swift in Sources */, 06799AE228F98E4800ACD94E /* RESTRequestFactory.swift in Sources */, A90763BD2B2857D50045ADF0 /* Socks5Connection.swift in Sources */, @@ -4269,6 +4291,7 @@ F0DDE4162B220458006B57A7 /* TransportProvider.swift in Sources */, 06799AEF28F98E4800ACD94E /* RetryStrategy.swift in Sources */, 06799AE128F98E4800ACD94E /* SSLPinningURLSessionDelegate.swift in Sources */, + F0164EBE2B4BFF940020268D /* ShadowsocksLoader.swift in Sources */, A9A1DE792AD5708E0073F689 /* TransportStrategy.swift in Sources */, A90763BF2B2857D50045ADF0 /* Socks5Handshake.swift in Sources */, A90763C52B2858B40045ADF0 /* AnyIPEndpoint+Socks5.swift in Sources */, @@ -4300,6 +4323,7 @@ 589E76C02A9378F100E502F3 /* RESTRequestExecutor.swift in Sources */, A90763C12B2858320045ADF0 /* URLSessionSocks5Transport.swift in Sources */, 06799AE528F98E4800ACD94E /* HTTP.swift in Sources */, + F0164ED12B4F2DCB0020268D /* AccessMethodIterator.swift in Sources */, A9D99B9A2A1F7C3200DE27D3 /* RESTTransport.swift in Sources */, A90763BB2B2857D50045ADF0 /* Socks5AddressType.swift in Sources */, 06799AE028F98E4800ACD94E /* RESTCoding.swift in Sources */, @@ -4493,6 +4517,7 @@ 58B2FDE02AA71D5C003EB5C6 /* TunnelSettings.swift in Sources */, A988DF2A2ADE880300D807EF /* TunnelSettingsV3.swift in Sources */, 58B2FDE42AA71D5C003EB5C6 /* SettingsManager.swift in Sources */, + F0164EBC2B482E430020268D /* AppStorage.swift in Sources */, 58B2FDE62AA71D5C003EB5C6 /* DeviceState.swift in Sources */, 58FE25BF2AA72311003D1918 /* MigrationManager.swift in Sources */, 58B2FDEF2AA720C4003EB5C6 /* ApplicationTarget.swift in Sources */, @@ -5039,8 +5064,11 @@ buildActionMask = 2147483647; files = ( 58B465702A98C53300467203 /* RequestExecutorTests.swift in Sources */, + F0164EBA2B4456D30020268D /* AccessMethodRepositoryStub.swift in Sources */, A917352129FAAA5200D5DCFD /* TransportStrategyTests.swift in Sources */, + F0164ED32B4F2F3E0020268D /* AccessMethodIteratorTests.swift in Sources */, 58FBFBE9291622580020E046 /* ExponentialBackoffTests.swift in Sources */, + F0164EC32B4C49D30020268D /* ShadowsocksLoaderStub.swift in Sources */, 58BDEB9D2A98F69E00F578F2 /* MemoryCache.swift in Sources */, 58BDEB9B2A98F58600F578F2 /* TimeServerProxy.swift in Sources */, 58BDEB992A98F4ED00F578F2 /* AnyTransport.swift in Sources */, diff --git a/ios/MullvadVPN/AccessMethodRepository/AccessMethodRepository.swift b/ios/MullvadVPN/AccessMethodRepository/AccessMethodRepository.swift deleted file mode 100644 index 90f4bf875ae4..000000000000 --- a/ios/MullvadVPN/AccessMethodRepository/AccessMethodRepository.swift +++ /dev/null @@ -1,116 +0,0 @@ -// -// AccessMethodRepository.swift -// MullvadVPN -// -// Created by Jon Petersson on 12/12/2023. -// Copyright © 2023 Mullvad VPN AB. All rights reserved. -// - -import Combine -import Foundation -import MullvadSettings - -class AccessMethodRepository: AccessMethodRepositoryProtocol { - let publisher: PassthroughSubject<[PersistentAccessMethod], Never> = .init() - - static let shared = AccessMethodRepository() - - private var defaultDirectMethod: PersistentAccessMethod { - PersistentAccessMethod( - id: UUID(uuidString: "C9DB7457-2A55-42C3-A926-C07F82131994")!, - name: "", - isEnabled: true, - proxyConfiguration: .direct - ) - } - - private var defaultBridgesMethod: PersistentAccessMethod { - PersistentAccessMethod( - id: UUID(uuidString: "8586E75A-CA7B-4432-B70D-EE65F3F95084")!, - name: "", - isEnabled: true, - proxyConfiguration: .bridges - ) - } - - init() { - add([defaultDirectMethod, defaultBridgesMethod]) - } - - func add(_ method: PersistentAccessMethod) { - add([method]) - } - - func add(_ methods: [PersistentAccessMethod]) { - var storedMethods = fetchAll() - - methods.forEach { method in - guard !storedMethods.contains(where: { $0.id == method.id }) else { return } - storedMethods.append(method) - } - - do { - try writeApiAccessMethods(storedMethods) - } catch { - print("Could not add access method(s): \(methods) \nError: \(error)") - } - } - - func update(_ method: PersistentAccessMethod) { - var methods = fetchAll() - - guard let index = methods.firstIndex(where: { $0.id == method.id }) else { return } - methods[index] = method - - do { - try writeApiAccessMethods(methods) - } catch { - print("Could not update access method: \(method) \nError: \(error)") - } - } - - func delete(id: UUID) { - var methods = fetchAll() - guard let index = methods.firstIndex(where: { $0.id == id }) else { return } - - // Prevent removing methods that have static UUIDs and are always present. - let method = methods[index] - if !method.kind.isPermanent { - methods.remove(at: index) - } - - do { - try writeApiAccessMethods(methods) - } catch { - print("Could not delete access method with id: \(id) \nError: \(error)") - } - } - - func fetch(by id: UUID) -> PersistentAccessMethod? { - fetchAll().first { $0.id == id } - } - - func fetchAll() -> [PersistentAccessMethod] { - (try? readApiAccessMethods()) ?? [] - } - - private func readApiAccessMethods() throws -> [PersistentAccessMethod] { - let parser = makeParser() - let data = try SettingsManager.store.read(key: .apiAccessMethods) - - return try parser.parseUnversionedPayload(as: [PersistentAccessMethod].self, from: data) - } - - private func writeApiAccessMethods(_ accessMethods: [PersistentAccessMethod]) throws { - let parser = makeParser() - let data = try parser.produceUnversionedPayload(accessMethods) - - try SettingsManager.store.write(data, for: .apiAccessMethods) - - publisher.send(accessMethods) - } - - private func makeParser() -> SettingsParser { - SettingsParser(decoder: JSONDecoder(), encoder: JSONEncoder()) - } -} diff --git a/ios/MullvadVPN/AppDelegate.swift b/ios/MullvadVPN/AppDelegate.swift index fe254248c221..0e6c65c316f5 100644 --- a/ios/MullvadVPN/AppDelegate.swift +++ b/ios/MullvadVPN/AppDelegate.swift @@ -42,6 +42,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD private var relayConstraintsObserver: TunnelBlockObserver! private let migrationManager = MigrationManager() + private(set) var accessMethodRepository = AccessMethodRepository() + // MARK: - Application lifecycle func application( @@ -92,15 +94,20 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD // This init cannot fail as long as the security group identifier is valid let sharedUserDefaults = UserDefaults(suiteName: ApplicationConfiguration.securityGroupIdentifier)! - let transportStrategy = TransportStrategy(sharedUserDefaults) + let transportStrategy = TransportStrategy( + sharedUserDefaults, + datasource: accessMethodRepository, + shadowsocksLoader: ShadowsocksLoader( + shadowsocksCache: shadowsocksCache, + relayCache: relayCache, + constraintsUpdater: constraintsUpdater + ) + ) let transportProvider = TransportProvider( urlSessionTransport: urlSessionTransport, - relayCache: relayCache, addressCache: addressCache, - shadowsocksCache: shadowsocksCache, - transportStrategy: transportStrategy, - constraintsUpdater: constraintsUpdater + transportStrategy: transportStrategy ) setUpTransportMonitor(transportProvider: transportProvider) setUpSimulatorHost(transportProvider: transportProvider) diff --git a/ios/MullvadVPN/Classes/AppPreferences.swift b/ios/MullvadVPN/Classes/AppPreferences.swift index 95e49ad2a023..292a42bddbb6 100644 --- a/ios/MullvadVPN/Classes/AppPreferences.swift +++ b/ios/MullvadVPN/Classes/AppPreferences.swift @@ -7,6 +7,7 @@ // import Foundation +import MullvadSettings protocol AppPreferencesDataSource { var isShownOnboarding: Bool { get set } @@ -18,49 +19,13 @@ enum AppStorageKey: String { case isShownOnboarding, isAgreedToTermsOfService, lastSeenChangeLogVersion } -@propertyWrapper -struct AppStorage { - let key: AppStorageKey - let defaultValue: Value - let container: UserDefaults - - var wrappedValue: Value { - get { - let value = container.value(forKey: key.rawValue) - return value.flatMap { $0 as? Value } ?? defaultValue - } - set { - if let anyOptional = newValue as? AnyOptional, - anyOptional.isNil { - container.removeObject(forKey: key.rawValue) - } else { - container.set(newValue, forKey: key.rawValue) - } - } - } - - init(wrappedValue: Value, _ key: AppStorageKey, container: UserDefaults = .standard) { - self.defaultValue = wrappedValue - self.container = container - self.key = key - } -} - final class AppPreferences: AppPreferencesDataSource { - @AppStorage(.isShownOnboarding) + @AppStorage(key: AppStorageKey.isShownOnboarding.rawValue, container: .standard) var isShownOnboarding = true - @AppStorage(.isAgreedToTermsOfService) + @AppStorage(key: AppStorageKey.isAgreedToTermsOfService.rawValue, container: .standard) var isAgreedToTermsOfService = false - @AppStorage(.lastSeenChangeLogVersion) + @AppStorage(key: AppStorageKey.lastSeenChangeLogVersion.rawValue, container: .standard) var lastSeenChangeLogVersion = "" } - -protocol AnyOptional { - var isNil: Bool { get } -} - -extension Optional: AnyOptional { - var isNil: Bool { self == nil } -} diff --git a/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift b/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift index 077214fc391f..c56c376721a8 100644 --- a/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift @@ -77,6 +77,7 @@ final class ApplicationCoordinator: Coordinator, Presenting, RootContainerViewCo private var tunnelObserver: TunnelObserver? private var appPreferences: AppPreferencesDataSource private var outgoingConnectionService: OutgoingConnectionServiceHandling + private var accessMethodRepository: AccessMethodRepositoryProtocol private var outOfTimeTimer: Timer? @@ -92,7 +93,8 @@ final class ApplicationCoordinator: Coordinator, Presenting, RootContainerViewCo devicesProxy: DeviceHandling, accountsProxy: RESTAccountHandling, outgoingConnectionService: OutgoingConnectionServiceHandling, - appPreferences: AppPreferencesDataSource + appPreferences: AppPreferencesDataSource, + accessMethodRepository: AccessMethodRepositoryProtocol ) { self.tunnelManager = tunnelManager self.storePaymentManager = storePaymentManager @@ -102,6 +104,7 @@ final class ApplicationCoordinator: Coordinator, Presenting, RootContainerViewCo self.accountsProxy = accountsProxy self.appPreferences = appPreferences self.outgoingConnectionService = outgoingConnectionService + self.accessMethodRepository = accessMethodRepository super.init() @@ -757,7 +760,8 @@ final class ApplicationCoordinator: Coordinator, Presenting, RootContainerViewCo let navigationController = CustomNavigationController() let coordinator = SettingsCoordinator( navigationController: navigationController, - interactorFactory: interactorFactory + interactorFactory: interactorFactory, + accessMethodRepository: accessMethodRepository ) coordinator.didFinish = { [weak self] _ in diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/List/ListAccessMethodCoordinator.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/List/ListAccessMethodCoordinator.swift index afd687847fb3..c355343d2c31 100644 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/List/ListAccessMethodCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/List/ListAccessMethodCoordinator.swift @@ -12,20 +12,24 @@ import UIKit class ListAccessMethodCoordinator: Coordinator, Presenting, SettingsChildCoordinator { let navigationController: UINavigationController - let accessMethodRepo: AccessMethodRepository = .shared + let accessMethodRepository: AccessMethodRepositoryProtocol let proxyConfigurationTester: ProxyConfigurationTester = .shared var presentationContext: UIViewController { navigationController } - init(navigationController: UINavigationController) { + init( + navigationController: UINavigationController, + accessMethodRepository: AccessMethodRepositoryProtocol + ) { self.navigationController = navigationController + self.accessMethodRepository = accessMethodRepository } func start(animated: Bool) { let listController = ListAccessMethodViewController( - interactor: ListAccessMethodInteractor(repo: accessMethodRepo) + interactor: ListAccessMethodInteractor(repo: accessMethodRepository) ) listController.delegate = self navigationController.pushViewController(listController, animated: animated) @@ -34,7 +38,7 @@ class ListAccessMethodCoordinator: Coordinator, Presenting, SettingsChildCoordin private func addNew() { let coordinator = AddAccessMethodCoordinator( navigationController: CustomNavigationController(), - accessMethodRepo: accessMethodRepo, + accessMethodRepo: accessMethodRepository, proxyConfigurationTester: proxyConfigurationTester ) @@ -48,7 +52,7 @@ class ListAccessMethodCoordinator: Coordinator, Presenting, SettingsChildCoordin let editCoordinator = EditAccessMethodCoordinator( navigationController: navigationController, - accessMethodRepo: accessMethodRepo, + accessMethodRepo: accessMethodRepository, proxyConfigurationTester: proxyConfigurationTester, methodIdentifier: item.id ) diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/AccessMethodKind.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/AccessMethodKind.swift deleted file mode 100644 index 88137dd14832..000000000000 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/AccessMethodKind.swift +++ /dev/null @@ -1,85 +0,0 @@ -// -// AccessMethodKind.swift -// MullvadVPN -// -// Created by pronebird on 02/11/2023. -// Copyright © 2023 Mullvad VPN AB. All rights reserved. -// - -import Foundation -import MullvadSettings - -/// A kind of API access method. -enum AccessMethodKind: Equatable, Hashable, CaseIterable { - /// Direct communication. - case direct - - /// Communication over bridges. - case bridges - - /// Communication over shadowsocks. - case shadowsocks - - /// Communication over socks v5 proxy. - case socks5 -} - -extension AccessMethodKind { - /// Returns `true` if the method is permanent and cannot be deleted. - var isPermanent: Bool { - switch self { - case .direct, .bridges: - true - case .shadowsocks, .socks5: - false - } - } - - /// Returns all access method kinds that can be added by user. - static var allUserDefinedKinds: [AccessMethodKind] { - allCases.filter { !$0.isPermanent } - } -} - -extension PersistentAccessMethod { - /// A kind of access method. - var kind: AccessMethodKind { - switch proxyConfiguration { - case .direct: - .direct - case .bridges: - .bridges - case .shadowsocks: - .shadowsocks - case .socks5: - .socks5 - } - } -} - -extension AccessMethodKind { - /// Returns localized description describing the access method. - var localizedDescription: String { - switch self { - case .direct: - NSLocalizedString("DIRECT", tableName: "APIAccess", value: "Direct", comment: "") - case .bridges: - NSLocalizedString("BRIDGES", tableName: "APIAccess", value: "Bridges", comment: "") - case .shadowsocks: - NSLocalizedString("SHADOWSOCKS", tableName: "APIAccess", value: "Shadowsocks", comment: "") - case .socks5: - NSLocalizedString("SOCKS_V5", tableName: "APIAccess", value: "Socks5", comment: "") - } - } - - /// Returns `true` if access method is configurable. - /// Methods that aren't configurable do not offer any additional configuration. - var hasProxyConfiguration: Bool { - switch self { - case .direct, .bridges: - false - case .shadowsocks, .socks5: - true - } - } -} diff --git a/ios/MullvadVPN/Coordinators/Settings/SettingsCoordinator.swift b/ios/MullvadVPN/Coordinators/Settings/SettingsCoordinator.swift index 4d9abb81f868..a0023dd23793 100644 --- a/ios/MullvadVPN/Coordinators/Settings/SettingsCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/Settings/SettingsCoordinator.swift @@ -7,6 +7,7 @@ // import MullvadLogging +import MullvadSettings import Operations import Routing import UIKit @@ -37,6 +38,7 @@ final class SettingsCoordinator: Coordinator, Presentable, Presenting, SettingsV private let interactorFactory: SettingsInteractorFactory private var currentRoute: SettingsNavigationRoute? private var modalRoute: SettingsNavigationRoute? + private let accessMethodRepository: AccessMethodRepositoryProtocol let navigationController: UINavigationController @@ -60,10 +62,12 @@ final class SettingsCoordinator: Coordinator, Presentable, Presenting, SettingsV /// - interactorFactory: an instance of a factory that produces interactors for the child routes. init( navigationController: UINavigationController, - interactorFactory: SettingsInteractorFactory + interactorFactory: SettingsInteractorFactory, + accessMethodRepository: AccessMethodRepositoryProtocol ) { self.navigationController = navigationController self.interactorFactory = interactorFactory + self.accessMethodRepository = accessMethodRepository } /// Start the coordinator fllow. @@ -246,7 +250,10 @@ final class SettingsCoordinator: Coordinator, Presentable, Presenting, SettingsV )) case .apiAccess: - return .childCoordinator(ListAccessMethodCoordinator(navigationController: navigationController)) + return .childCoordinator(ListAccessMethodCoordinator( + navigationController: navigationController, + accessMethodRepository: accessMethodRepository + )) case .faq: // Handled separately and presented as a modal. diff --git a/ios/MullvadVPN/SceneDelegate.swift b/ios/MullvadVPN/SceneDelegate.swift index 0719559d7025..cb7347c9e610 100644 --- a/ios/MullvadVPN/SceneDelegate.swift +++ b/ios/MullvadVPN/SceneDelegate.swift @@ -31,6 +31,10 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate, SettingsMigrationUIHand UIApplication.shared.delegate as! AppDelegate } + private var accessMethodRepository: AccessMethodRepositoryProtocol { + appDelegate.accessMethodRepository + } + private var tunnelManager: TunnelManager { appDelegate.tunnelManager } @@ -71,7 +75,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate, SettingsMigrationUIHand outgoingConnectionService: OutgoingConnectionService( outgoingConnectionProxy: OutgoingConnectionProxy(urlSession: URLSession(configuration: .ephemeral)) ), - appPreferences: AppPreferences() + appPreferences: AppPreferences(), + accessMethodRepository: accessMethodRepository ) appCoordinator?.onShowSettings = { [weak self] in diff --git a/ios/MullvadVPN/View controllers/Settings/SettingsInteractor.swift b/ios/MullvadVPN/View controllers/Settings/SettingsInteractor.swift index 58b5201cf6c3..c3f07dc72c1d 100644 --- a/ios/MullvadVPN/View controllers/Settings/SettingsInteractor.swift +++ b/ios/MullvadVPN/View controllers/Settings/SettingsInteractor.swift @@ -19,7 +19,9 @@ final class SettingsInteractor { tunnelManager.deviceState } - init(tunnelManager: TunnelManager) { + init( + tunnelManager: TunnelManager + ) { self.tunnelManager = tunnelManager let tunnelObserver = diff --git a/ios/MullvadVPNTests/APIAccessMethodsTests.swift b/ios/MullvadVPNTests/APIAccessMethodsTests.swift index 2163c900c6d4..f30886f5e35a 100644 --- a/ios/MullvadVPNTests/APIAccessMethodsTests.swift +++ b/ios/MullvadVPNTests/APIAccessMethodsTests.swift @@ -21,14 +21,15 @@ final class APIAccessMethodsTests: XCTestCase { } override func tearDownWithError() throws { - let repository = AccessMethodRepository.shared + let repository = AccessMethodRepository() repository.fetchAll().forEach { repository.delete(id: $0.id) } } func testDefaultAccessMethodsExist() throws { - let storedMethods = AccessMethodRepository.shared.fetchAll() + let repository = AccessMethodRepository() + let storedMethods = repository.fetchAll() let hasDirectMethod = storedMethods.contains { method in method.kind == .direct @@ -43,60 +44,69 @@ final class APIAccessMethodsTests: XCTestCase { } func testAddingSocks5AccessMethod() throws { + let repository = AccessMethodRepository() + let uuid = UUID() let methodToStore = socks5AccessMethod(with: uuid) + repository.add(methodToStore) - AccessMethodRepository.shared.add(methodToStore) - let storedMethod = AccessMethodRepository.shared.fetch(by: uuid) + let storedMethod = repository.fetch(by: uuid) XCTAssertEqual(methodToStore.id, storedMethod?.id) } func testAddingShadowSocksAccessMethod() throws { + let repository = AccessMethodRepository() + let uuid = UUID() let methodToStore = shadowsocksAccessMethod(with: uuid) + repository.add(methodToStore) - AccessMethodRepository.shared.add(methodToStore) - let storedMethod = AccessMethodRepository.shared.fetch(by: uuid) + let storedMethod = repository.fetch(by: uuid) XCTAssertEqual(methodToStore.id, storedMethod?.id) } func testAddingDuplicateAccessMethodDoesNothing() throws { + let repository = AccessMethodRepository() + let methodToStore = socks5AccessMethod(with: UUID()) - AccessMethodRepository.shared.add(methodToStore) - AccessMethodRepository.shared.add(methodToStore) - let storedMethods = AccessMethodRepository.shared.fetchAll() + repository.add(methodToStore) + repository.add(methodToStore) + + let storedMethods = repository.fetchAll() // Account for .direct and .bridges that are always added by default. XCTAssertEqual(storedMethods.count, 3) } func testUpdatingAccessMethod() throws { + let repository = AccessMethodRepository() + let uuid = UUID() var methodToStore = socks5AccessMethod(with: uuid) - - AccessMethodRepository.shared.add(methodToStore) + repository.add(methodToStore) let newName = "Renamed method" methodToStore.name = newName - AccessMethodRepository.shared.update(methodToStore) + repository.update(methodToStore) - let storedMethod = AccessMethodRepository.shared.fetch(by: uuid) + let storedMethod = repository.fetch(by: uuid) XCTAssertEqual(storedMethod?.name, newName) } func testDeletingAccessMethod() throws { + let repository = AccessMethodRepository() let uuid = UUID() let methodToStore = socks5AccessMethod(with: uuid) - AccessMethodRepository.shared.add(methodToStore) - AccessMethodRepository.shared.delete(id: uuid) + repository.add(methodToStore) + repository.delete(id: uuid) - let storedMethod = AccessMethodRepository.shared.fetch(by: uuid) + let storedMethod = repository.fetch(by: uuid) XCTAssertNil(storedMethod) } diff --git a/ios/PacketTunnel/PacketTunnelProvider/PacketTunnelProvider.swift b/ios/PacketTunnel/PacketTunnelProvider/PacketTunnelProvider.swift index cd3ccc8bd1f7..735ee455b9b9 100644 --- a/ios/PacketTunnel/PacketTunnelProvider/PacketTunnelProvider.swift +++ b/ios/PacketTunnel/PacketTunnelProvider/PacketTunnelProvider.swift @@ -9,6 +9,7 @@ import Foundation import MullvadLogging import MullvadREST +import MullvadSettings import MullvadTypes import NetworkExtension import PacketTunnelCore @@ -41,15 +42,20 @@ class PacketTunnelProvider: NEPacketTunnelProvider { // This init cannot fail as long as the security group identifier is valid let sharedUserDefaults = UserDefaults(suiteName: ApplicationConfiguration.securityGroupIdentifier)! - let transportStrategy = TransportStrategy(sharedUserDefaults) + let transportStrategy = TransportStrategy( + sharedUserDefaults, + datasource: AccessMethodRepository(), + shadowsocksLoader: ShadowsocksLoader( + shadowsocksCache: shadowsocksCache, + relayCache: relayCache, + constraintsUpdater: constraintsUpdater + ) + ) let transportProvider = TransportProvider( urlSessionTransport: urlSessionTransport, - relayCache: relayCache, addressCache: addressCache, - shadowsocksCache: shadowsocksCache, - transportStrategy: transportStrategy, - constraintsUpdater: constraintsUpdater + transportStrategy: transportStrategy ) super.init()