summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorBug Magnet <marco.nikic@mullvad.net>2025-05-13 15:44:49 +0200
committerBug Magnet <marco.nikic@mullvad.net>2025-05-13 15:44:49 +0200
commitce62828a21cb65f2b0d1710b7d92b4e55a518408 (patch)
treee9a629be1c92a76767822f7bbf96b2617d18f9aa
parent609d35153a26b58031a52fa4274e47bbedd5c89b (diff)
parentf3b8790ae48628a8278ac432d58ed4cac3247535 (diff)
downloadmullvadvpn-ce62828a21cb65f2b0d1710b7d92b4e55a518408.tar.xz
mullvadvpn-ce62828a21cb65f2b0d1710b7d92b4e55a518408.zip
Merge branch 'fix-slowness-of-the-reconnect-button-ios-685'
-rw-r--r--ios/CHANGELOG.md1
-rw-r--r--ios/MullvadVPN/SimulatorTunnelProvider/SimulatorTunnelProviderHost.swift38
-rw-r--r--ios/MullvadVPN/TunnelManager/Tunnel+Messaging.swift22
-rw-r--r--ios/MullvadVPN/TunnelManager/TunnelManager.swift25
-rw-r--r--ios/MullvadVPN/TunnelManager/TunnelState.swift19
-rw-r--r--ios/MullvadVPNTests/MullvadSettings/APIAccessMethodsTests.swift2
-rw-r--r--ios/MullvadVPNTests/MullvadSettings/IPOverrideRepositoryTests.swift2
-rw-r--r--ios/MullvadVPNTests/MullvadSettings/InMemorySettingsStore.swift4
-rw-r--r--ios/MullvadVPNTests/MullvadSettings/MigrationManagerTests.swift2
-rw-r--r--ios/MullvadVPNTests/MullvadVPN/TunnelManager/TunnelManagerTests.swift78
-rw-r--r--ios/MullvadVPNTests/MullvadVPN/View controllers/SelectLocation/CustomListRepositoryTests.swift2
-rw-r--r--ios/MullvadVPNTests/MullvadVPN/View controllers/Tunnel/DestinationDescriberTests.swift2
-rw-r--r--ios/PacketTunnelCore/IPC/AppMessageHandler.swift5
13 files changed, 175 insertions, 27 deletions
diff --git a/ios/CHANGELOG.md b/ios/CHANGELOG.md
index 39573cc968..66be5a2682 100644
--- a/ios/CHANGELOG.md
+++ b/ios/CHANGELOG.md
@@ -27,6 +27,7 @@ Line wrap the file at 100 chars. Th
### Changed
- Improve the filter view to display the number of available servers based on selected criteria.
+- Make the app feel more responsive when reconnecting.
## 2025.2 - 2025-02-08
### Added
diff --git a/ios/MullvadVPN/SimulatorTunnelProvider/SimulatorTunnelProviderHost.swift b/ios/MullvadVPN/SimulatorTunnelProvider/SimulatorTunnelProviderHost.swift
index 75a92e1b03..2bc8d07832 100644
--- a/ios/MullvadVPN/SimulatorTunnelProvider/SimulatorTunnelProviderHost.swift
+++ b/ios/MullvadVPN/SimulatorTunnelProvider/SimulatorTunnelProviderHost.swift
@@ -26,6 +26,8 @@ final class SimulatorTunnelProviderHost: SimulatorTunnelProviderDelegate, @unche
private let providerLogger = Logger(label: "SimulatorTunnelProviderHost")
private let dispatchQueue = DispatchQueue(label: "SimulatorTunnelProviderHostQueue")
+ public var onHandleProviderMessage: ((TunnelProviderMessage) -> Void)?
+
init(
relaySelector: RelaySelectorProtocol,
transportProvider: TransportProvider,
@@ -110,6 +112,7 @@ final class SimulatorTunnelProviderHost: SimulatorTunnelProviderDelegate, @unche
}
}
+ // swiftlint:disable:next function_body_length
private func handleProviderMessage(
_ message: TunnelProviderMessage,
completionHandler: ((Data?) -> Void)?
@@ -143,11 +146,17 @@ final class SimulatorTunnelProviderHost: SimulatorTunnelProviderDelegate, @unche
break
}
- setInternalStateConnected(with: selectedRelays)
+ setInternalStateReconnecting(with: selectedRelays)
reasserting = false
completionHandler?(nil)
+ // The PacketTunnel does not run on the simulator.
+ // Fake a reconnecting state that becomes connected after long enough for the UI to change appropriately.
+ dispatchQueue.asyncAfter(deadline: .now() + .milliseconds(600)) { [weak self] in
+ self?.setInternalStateConnected(with: self?.selectedRelays)
+ }
+
case let .sendURLRequest(proxyRequest):
urlRequestProxy.sendRequest(proxyRequest) { response in
var reply: Data?
@@ -189,6 +198,8 @@ final class SimulatorTunnelProviderHost: SimulatorTunnelProviderDelegate, @unche
case .privateKeyRotation:
completionHandler?(nil)
}
+
+ onHandleProviderMessage?(message)
}
private func pickRelays() throws -> SelectedRelays {
@@ -199,9 +210,34 @@ final class SimulatorTunnelProviderHost: SimulatorTunnelProviderDelegate, @unche
)
}
+ private func setInternalStateReconnecting(with selectedRelays: SelectedRelays?) {
+ guard let selectedRelays = selectedRelays else { return }
+
+ do {
+ let settings = try SettingsManager.readSettings()
+ observedState = .reconnecting(
+ ObservedConnectionState(
+ selectedRelays: selectedRelays,
+ relayConstraints: settings.relayConstraints,
+ networkReachability: .reachable,
+ connectionAttemptCount: 0,
+ transportLayer: .udp,
+ remotePort: selectedRelays.entry?.endpoint.ipv4Relay.port ?? selectedRelays.exit.endpoint.ipv4Relay
+ .port,
+ isPostQuantum: settings.tunnelQuantumResistance.isEnabled,
+ isDaitaEnabled: settings.daita.daitaState.isEnabled
+ )
+ )
+ } catch {
+ providerLogger.error(error: error, message: "Failed to read device settings")
+ }
+ }
+
private func setInternalStateConnected(with selectedRelays: SelectedRelays?) {
guard let selectedRelays = selectedRelays else { return }
+ self.selectedRelays = selectedRelays
+
do {
let settings = try SettingsManager.readSettings()
observedState = .connected(
diff --git a/ios/MullvadVPN/TunnelManager/Tunnel+Messaging.swift b/ios/MullvadVPN/TunnelManager/Tunnel+Messaging.swift
index 71d8238545..fa00cac48d 100644
--- a/ios/MullvadVPN/TunnelManager/Tunnel+Messaging.swift
+++ b/ios/MullvadVPN/TunnelManager/Tunnel+Messaging.swift
@@ -25,14 +25,14 @@ extension TunnelProtocol {
/// Request packet tunnel process to reconnect the tunnel with the given relays.
func reconnectTunnel(
to nextRelays: NextRelays,
- completionHandler: @escaping @Sendable (Result<Void, Error>) -> Void
+ completionHandler: @escaping @Sendable (Result<ObservedState, Error>) -> Void
) -> Cancellable {
let operation = SendTunnelProviderMessageOperation(
dispatchQueue: dispatchQueue,
backgroundTaskProvider: backgroundTaskProvider,
tunnel: self,
message: .reconnectTunnel(nextRelays),
- decoderHandler: { _ in () },
+ decoderHandler: mapObservedState(data:),
completionHandler: completionHandler
)
@@ -45,20 +45,12 @@ extension TunnelProtocol {
func getTunnelStatus(
completionHandler: @escaping @Sendable (Result<ObservedState, Error>) -> Void
) -> Cancellable {
- let decoderHandler: (Data?) throws -> ObservedState = { data in
- if let data {
- return try TunnelProviderReply<ObservedState>(messageData: data).value
- } else {
- throw EmptyTunnelProviderResponseError()
- }
- }
-
let operation = SendTunnelProviderMessageOperation(
dispatchQueue: dispatchQueue,
backgroundTaskProvider: backgroundTaskProvider,
tunnel: self,
message: .getTunnelStatus,
- decoderHandler: decoderHandler,
+ decoderHandler: mapObservedState(data:),
completionHandler: completionHandler
)
@@ -152,6 +144,14 @@ extension TunnelProtocol {
return operation
}
+ func mapObservedState(data: Data?) throws -> ObservedState {
+ if let data {
+ return try TunnelProviderReply<ObservedState>(messageData: data).value
+ } else {
+ throw EmptyTunnelProviderResponseError()
+ }
+ }
+
/// Notify tunnel about private key rotation.
func notifyKeyRotation(
completionHandler: @escaping @Sendable (Result<Void, Error>) -> Void
diff --git a/ios/MullvadVPN/TunnelManager/TunnelManager.swift b/ios/MullvadVPN/TunnelManager/TunnelManager.swift
index af1a41f124..090a8d9bb1 100644
--- a/ios/MullvadVPN/TunnelManager/TunnelManager.swift
+++ b/ios/MullvadVPN/TunnelManager/TunnelManager.swift
@@ -20,7 +20,7 @@ import WireGuardKitTypes
/// Interval used for periodic polling of tunnel relay status when tunnel is establishing
/// connection.
-private let establishingTunnelStatusPollInterval: Duration = .seconds(3)
+private let establishingTunnelStatusPollInterval: Duration = .seconds(1)
/// Interval used for periodic polling of tunnel connectivity status once the tunnel connection
/// is established.
@@ -286,6 +286,8 @@ final class TunnelManager: StorePaymentObserver, @unchecked Sendable {
}
func reconnectTunnel(selectNewRelay: Bool, completionHandler: (@Sendable (Error?) -> Void)? = nil) {
+ // Start polling the tunnel immediately when the user reconnects
+ startPollingTunnelStatus(interval: establishingTunnelStatusPollInterval)
let operation = AsyncBlockOperation(dispatchQueue: internalQueue) { finish -> Cancellable in
do {
guard let tunnel = self.tunnel else {
@@ -293,6 +295,19 @@ final class TunnelManager: StorePaymentObserver, @unchecked Sendable {
}
return tunnel.reconnectTunnel(to: selectNewRelay ? .random : .current) { result in
+ if case let .success(observedState) = result {
+ guard let connectionState = observedState.connectionState else { return }
+
+ // This makes the app feel very responsive when the user wants to reconnect
+ // If the tunnel is already connected, at worst the next tunnel status poll will correct the state
+ self._tunnelStatus.state = .reconnecting(
+ connectionState.selectedRelays,
+ isPostQuantum: connectionState.isPostQuantum,
+ isDaita: connectionState.isDaitaEnabled
+ )
+ self._tunnelStatus.observedState = observedState
+ }
+
finish(result.error)
}
} catch {
@@ -1043,9 +1058,12 @@ final class TunnelManager: StorePaymentObserver, @unchecked Sendable {
// MARK: - Tunnel status polling
private func startPollingTunnelStatus(interval: Duration) {
- guard !isPolling else { return }
-
+ /*
+ Ignore idempotency, otherwise the timer will not be using the correct time interval
+ when switching between states, until the tunnel disconnects.
+ */
isPolling = true
+ tunnelStatusPollTimer?.cancel()
logger.debug("Start polling tunnel status every \(interval.logFormat()).")
@@ -1056,7 +1074,6 @@ final class TunnelManager: StorePaymentObserver, @unchecked Sendable {
timer.schedule(wallDeadline: .now() + interval, repeating: interval.timeInterval)
timer.activate()
- tunnelStatusPollTimer?.cancel()
tunnelStatusPollTimer = timer
}
diff --git a/ios/MullvadVPN/TunnelManager/TunnelState.swift b/ios/MullvadVPN/TunnelManager/TunnelState.swift
index dafa26d099..648dfd78c9 100644
--- a/ios/MullvadVPN/TunnelManager/TunnelState.swift
+++ b/ios/MullvadVPN/TunnelManager/TunnelState.swift
@@ -12,12 +12,25 @@ import MullvadTypes
import PacketTunnelCore
@preconcurrency import WireGuardKitTypes
-/// A struct describing the tunnel status.
+/// Describes the tunnel status.
+///
+/// The `state` property is reflected in the main view of the app, and typically shows
+/// whether the VPN is connected, connecting, or disconnected.
+/// On top of that, a banner might be shown in cases `state` is either `waitingForConnectivity` or `error`
+///
+/// The `observedState` contains metadata about the PacketTunnel, and can be used to infer various details such as
+/// - A reason why the app would enter the blocked state
+/// - Whether networking is available from within the `PacketTunnel` process
+/// - How many times a reconnection was attempted
+/// - Which protocol layer is used by the `PacketTunnel` (TCP, UDP etc...)
+///
+/// And so on, this is a non-exhaustive list.
struct TunnelStatus: Equatable, CustomStringConvertible, Sendable {
- /// Tunnel status returned by tunnel process.
+ /// Reflects the `PacketTunnel`'s internal state.
var observedState: ObservedState = .disconnected
- /// Tunnel state.
+ /// Internal state used by the UI Process to manage transitions and UI updates.
+ /// Directly affects the UI, what user actions are available.
var state: TunnelState = .disconnected
var description: String {
diff --git a/ios/MullvadVPNTests/MullvadSettings/APIAccessMethodsTests.swift b/ios/MullvadVPNTests/MullvadSettings/APIAccessMethodsTests.swift
index 72e1ae4cb0..92e3235b1b 100644
--- a/ios/MullvadVPNTests/MullvadSettings/APIAccessMethodsTests.swift
+++ b/ios/MullvadVPNTests/MullvadSettings/APIAccessMethodsTests.swift
@@ -19,7 +19,7 @@ final class APIAccessMethodsTests: XCTestCase {
}
override static func tearDown() {
- SettingsManager.unitTestStore = nil
+ store.reset()
}
override func tearDownWithError() throws {
diff --git a/ios/MullvadVPNTests/MullvadSettings/IPOverrideRepositoryTests.swift b/ios/MullvadVPNTests/MullvadSettings/IPOverrideRepositoryTests.swift
index 6acdb527ae..3301c8b688 100644
--- a/ios/MullvadVPNTests/MullvadSettings/IPOverrideRepositoryTests.swift
+++ b/ios/MullvadVPNTests/MullvadSettings/IPOverrideRepositoryTests.swift
@@ -19,7 +19,7 @@ final class IPOverrideRepositoryTests: XCTestCase {
}
override static func tearDown() {
- SettingsManager.unitTestStore = nil
+ store.reset()
}
override func tearDownWithError() throws {
diff --git a/ios/MullvadVPNTests/MullvadSettings/InMemorySettingsStore.swift b/ios/MullvadVPNTests/MullvadSettings/InMemorySettingsStore.swift
index 18cc9fdf59..dcdb108a9b 100644
--- a/ios/MullvadVPNTests/MullvadSettings/InMemorySettingsStore.swift
+++ b/ios/MullvadVPNTests/MullvadSettings/InMemorySettingsStore.swift
@@ -28,4 +28,8 @@ class InMemorySettingsStore<ThrownError: Error>: SettingsStore, @unchecked Senda
func delete(key: SettingsKey) throws {
settings.removeValue(forKey: key)
}
+
+ func reset() {
+ settings.removeAll()
+ }
}
diff --git a/ios/MullvadVPNTests/MullvadSettings/MigrationManagerTests.swift b/ios/MullvadVPNTests/MullvadSettings/MigrationManagerTests.swift
index 078b0f5a62..f62ca74403 100644
--- a/ios/MullvadVPNTests/MullvadSettings/MigrationManagerTests.swift
+++ b/ios/MullvadVPNTests/MullvadSettings/MigrationManagerTests.swift
@@ -22,7 +22,7 @@ final class MigrationManagerTests: XCTestCase {
}
override static func tearDown() {
- SettingsManager.unitTestStore = nil
+ store.reset()
}
override func setUpWithError() throws {
diff --git a/ios/MullvadVPNTests/MullvadVPN/TunnelManager/TunnelManagerTests.swift b/ios/MullvadVPNTests/MullvadVPN/TunnelManager/TunnelManagerTests.swift
index 3119af256f..2169d0100d 100644
--- a/ios/MullvadVPNTests/MullvadVPN/TunnelManager/TunnelManagerTests.swift
+++ b/ios/MullvadVPNTests/MullvadVPN/TunnelManager/TunnelManagerTests.swift
@@ -5,6 +5,8 @@
// Created by Marco Nikic on 2023-10-02.
// Copyright © 2025 Mullvad VPN AB. All rights reserved.
//
+
+// swiftlint:disable function_body_length
@testable import MullvadREST
@testable import MullvadMockData
@@ -34,7 +36,7 @@ class TunnelManagerTests: XCTestCase {
}
override static func tearDown() {
- SettingsManager.unitTestStore = nil
+ store.reset()
}
override func setUp() async throws {
@@ -127,7 +129,6 @@ class TunnelManagerTests: XCTestCase {
}
/// This test verifies tunnel gets out of `blockedState` after constraints are satisfied.
- // swiftlint:disable:next function_body_length
func testExitBlockedStateAfterSatisfyingConstraints() async throws {
let blockedExpectation = expectation(description: "Relay constraints aren't satisfied!")
let connectedExpectation = expectation(description: "Connected!")
@@ -201,6 +202,77 @@ class TunnelManagerTests: XCTestCase {
)
}
+ /// This test verifies that a refresh tunnel status operation is scheduled whenever the tunnel is being restarted
+ func testReconnectingTunnelRefreshesItsStatus() async throws {
+ accountProxy.createAccountResult = .success(NewAccountData.mockValue())
+
+ let relaySelector = RelaySelectorStub { _ in
+ try RelaySelectorStub.nonFallible().selectRelays(
+ tunnelSettings: LatestTunnelSettings(),
+ connectionAttemptCount: 0
+ )
+ }
+
+ let tunnelManager = TunnelManager(
+ backgroundTaskProvider: application,
+ tunnelStore: TunnelStore(application: application),
+ relayCacheTracker: relayCacheTracker,
+ accountsProxy: accountProxy,
+ devicesProxy: devicesProxy,
+ apiProxy: apiProxy,
+ accessTokenManager: accessTokenManager,
+ relaySelector: relaySelector
+ )
+
+ let simulatorTunnelProviderHost = SimulatorTunnelProviderHost(
+ relaySelector: relaySelector,
+ transportProvider: transportProvider,
+ apiTransportProvider: APITransportProvider(
+ requestFactory: MullvadApiRequestFactory(
+ apiContext: apiContext,
+ encoder: REST.Coding.makeJSONEncoder()
+ )
+ )
+ )
+
+ SimulatorTunnelProvider.shared.delegate = simulatorTunnelProviderHost
+
+ _ = try await tunnelManager.setNewAccount()
+ XCTAssertTrue(tunnelManager.deviceState.isLoggedIn)
+
+ let connectedExpectation = expectation(description: "Connected")
+ let reconnectingExpectation = expectation(description: "Reconnecting")
+ let tunnelObserver = TunnelBlockObserver(
+ didUpdateTunnelStatus: { _, tunnelStatus in
+ switch tunnelStatus.state {
+ case .connected: connectedExpectation.fulfill()
+ case .reconnecting: reconnectingExpectation.fulfill()
+ default: return
+ }
+ }
+ )
+
+ self.tunnelObserver = tunnelObserver
+ tunnelManager.addObserver(tunnelObserver)
+ tunnelManager.startTunnel()
+
+ await fulfillment(of: [connectedExpectation])
+
+ let reconnectMessageExpectation = expectation(description: "Did witness reconnect message")
+
+ simulatorTunnelProviderHost.onHandleProviderMessage = { message in
+ switch message {
+ case .reconnectTunnel: reconnectMessageExpectation.fulfill()
+ default: break
+ }
+ }
+
+ tunnelManager.reconnectTunnel(selectNewRelay: false)
+ await fulfillment(
+ of: [reconnectMessageExpectation, reconnectingExpectation], enforceOrder: true
+ )
+ }
+
/// This test verifies tunnel gets disconnected and reconnected on config reapply.
func testReapplyingConfigDisconnectsAndReconnects() async throws {
var connectedExpectation = expectation(description: "Connected!")
@@ -265,3 +337,5 @@ class TunnelManagerTests: XCTestCase {
)
}
}
+
+// swiftlint:enable function_body_length
diff --git a/ios/MullvadVPNTests/MullvadVPN/View controllers/SelectLocation/CustomListRepositoryTests.swift b/ios/MullvadVPNTests/MullvadVPN/View controllers/SelectLocation/CustomListRepositoryTests.swift
index bde0b34505..906cdf2c8f 100644
--- a/ios/MullvadVPNTests/MullvadVPN/View controllers/SelectLocation/CustomListRepositoryTests.swift
+++ b/ios/MullvadVPNTests/MullvadVPN/View controllers/SelectLocation/CustomListRepositoryTests.swift
@@ -19,7 +19,7 @@ class CustomListRepositoryTests: XCTestCase {
}
override class func tearDown() {
- SettingsManager.unitTestStore = nil
+ store.reset()
}
override func tearDownWithError() throws {
diff --git a/ios/MullvadVPNTests/MullvadVPN/View controllers/Tunnel/DestinationDescriberTests.swift b/ios/MullvadVPNTests/MullvadVPN/View controllers/Tunnel/DestinationDescriberTests.swift
index 1ad55a8e71..678a0d2d9a 100644
--- a/ios/MullvadVPNTests/MullvadVPN/View controllers/Tunnel/DestinationDescriberTests.swift
+++ b/ios/MullvadVPNTests/MullvadVPN/View controllers/Tunnel/DestinationDescriberTests.swift
@@ -20,7 +20,7 @@ final class DestinationDescriberTests: XCTestCase {
}
override static func tearDown() {
- SettingsManager.unitTestStore = nil
+ store.reset()
}
func testDescribeList() throws {
diff --git a/ios/PacketTunnelCore/IPC/AppMessageHandler.swift b/ios/PacketTunnelCore/IPC/AppMessageHandler.swift
index fc99fa7c22..1f16df4a08 100644
--- a/ios/PacketTunnelCore/IPC/AppMessageHandler.swift
+++ b/ios/PacketTunnelCore/IPC/AppMessageHandler.swift
@@ -69,7 +69,10 @@ public struct AppMessageHandler {
case let .reconnectTunnel(nextRelay):
packetTunnelActor.reconnect(to: nextRelay, reconnectReason: ActorReconnectReason.userInitiated)
- return nil
+ // Instead of waiting for the UI process to send another `getTunnelStatus` message, reply immediately that the PacketTunnel is reconnecting
+ guard let observedState = await packetTunnelActor.observedState.connectionState else { return nil }
+ let reconnectingState = ObservedState.reconnecting(observedState)
+ return encodeReply(reconnectingState)
}
}