diff options
| author | Bug Magnet <marco.nikic@mullvad.net> | 2025-05-13 15:44:49 +0200 |
|---|---|---|
| committer | Bug Magnet <marco.nikic@mullvad.net> | 2025-05-13 15:44:49 +0200 |
| commit | ce62828a21cb65f2b0d1710b7d92b4e55a518408 (patch) | |
| tree | e9a629be1c92a76767822f7bbf96b2617d18f9aa | |
| parent | 609d35153a26b58031a52fa4274e47bbedd5c89b (diff) | |
| parent | f3b8790ae48628a8278ac432d58ed4cac3247535 (diff) | |
| download | mullvadvpn-ce62828a21cb65f2b0d1710b7d92b4e55a518408.tar.xz mullvadvpn-ce62828a21cb65f2b0d1710b7d92b4e55a518408.zip | |
Merge branch 'fix-slowness-of-the-reconnect-button-ios-685'
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) } } |
