From 2a17914bf9db3ffd41badc112aafcfa2c1f7d98e Mon Sep 17 00:00:00 2001 From: Andrew Bulhak Date: Wed, 15 Jan 2025 15:19:41 +0100 Subject: [PATCH] Put custom list name or (unfmtd) location name into connect button --- .../Relay/RelaySelector+Shadowsocks.swift | 5 +- .../Relay/RelaySelector+Wireguard.swift | 5 +- ios/MullvadREST/Relay/RelaySelector.swift | 10 -- ios/MullvadREST/Relay/RelayWithLocation.swift | 34 ++++- ios/MullvadVPN.xcodeproj/project.pbxproj | 10 ++ .../ConnectionViewComponentPreview.swift | 6 +- .../ConnectionViewViewModel.swift | 35 +++++- .../ConnectionView/DestinationDescriber.swift | 73 +++++++++++ .../Tunnel/TunnelViewController.swift | 9 +- .../Tunnel/DestinationDescriberTests.swift | 118 ++++++++++++++++++ 10 files changed, 288 insertions(+), 17 deletions(-) create mode 100644 ios/MullvadVPN/View controllers/Tunnel/ConnectionView/DestinationDescriber.swift create mode 100644 ios/MullvadVPNTests/MullvadVPN/View controllers/Tunnel/DestinationDescriberTests.swift diff --git a/ios/MullvadREST/Relay/RelaySelector+Shadowsocks.swift b/ios/MullvadREST/Relay/RelaySelector+Shadowsocks.swift index f529b9b92474..41e4df868c7d 100644 --- a/ios/MullvadREST/Relay/RelaySelector+Shadowsocks.swift +++ b/ios/MullvadREST/Relay/RelaySelector+Shadowsocks.swift @@ -42,7 +42,10 @@ extension RelaySelector { filter: RelayConstraint, in relaysResponse: REST.ServerRelaysResponse ) -> REST.BridgeRelay? { - let mappedBridges = mapRelays(relays: relaysResponse.bridge.relays, locations: relaysResponse.locations) + let mappedBridges = RelayWithLocation.locateRelays( + relays: relaysResponse.bridge.relays, + locations: relaysResponse.locations + ) let filteredRelays = (try? applyConstraints( location, filterConstraint: filter, diff --git a/ios/MullvadREST/Relay/RelaySelector+Wireguard.swift b/ios/MullvadREST/Relay/RelaySelector+Wireguard.swift index 1cb1d20ae5fe..2d0d1017b0c2 100644 --- a/ios/MullvadREST/Relay/RelaySelector+Wireguard.swift +++ b/ios/MullvadREST/Relay/RelaySelector+Wireguard.swift @@ -18,7 +18,10 @@ extension RelaySelector { filterConstraint: RelayConstraint, daitaEnabled: Bool ) throws -> [RelayWithLocation] { - let mappedRelays = mapRelays(relays: relays.wireguard.relays, locations: relays.locations) + let mappedRelays = RelayWithLocation.locateRelays( + relays: relays.wireguard.relays, + locations: relays.locations + ) return try applyConstraints( relayConstraint, diff --git a/ios/MullvadREST/Relay/RelaySelector.swift b/ios/MullvadREST/Relay/RelaySelector.swift index d20f3039bc70..b36729999ef2 100644 --- a/ios/MullvadREST/Relay/RelaySelector.swift +++ b/ios/MullvadREST/Relay/RelaySelector.swift @@ -62,16 +62,6 @@ public enum RelaySelector { return randomRelay } - static func mapRelays( - relays: [T], - locations: [String: REST.ServerLocation] - ) -> [RelayWithLocation] { - relays.compactMap { relay in - guard let serverLocation = locations[relay.location] else { return nil } - return makeRelayWithLocationFrom(serverLocation, relay: relay) - } - } - /// Produce a list of `RelayWithLocation` items satisfying the given constraints static func applyConstraints( _ relayConstraint: RelayConstraint, diff --git a/ios/MullvadREST/Relay/RelayWithLocation.swift b/ios/MullvadREST/Relay/RelayWithLocation.swift index 0cba62661bc0..d1b8320f57b2 100644 --- a/ios/MullvadREST/Relay/RelayWithLocation.swift +++ b/ios/MullvadREST/Relay/RelayWithLocation.swift @@ -13,7 +13,7 @@ public struct RelayWithLocation { let relay: T public let serverLocation: Location - func matches(location: RelayLocation) -> Bool { + public func matches(location: RelayLocation) -> Bool { return switch location { case let .country(countryCode): serverLocation.countryCode == countryCode @@ -28,6 +28,38 @@ public struct RelayWithLocation { relay.hostname == hostname } } + + init(relay: T, serverLocation: Location) { + self.relay = relay + self.serverLocation = serverLocation + } + + init?(_ relay: T, locations: [String: REST.ServerLocation]) { + let locationComponents = relay.location.split(separator: "-") + guard + locationComponents.count > 1, + let serverLocation = locations[relay.location] + else { return nil } + + self.relay = relay + self.serverLocation = Location( + country: serverLocation.country, + countryCode: String(locationComponents[0]), + city: serverLocation.city, + cityCode: String(locationComponents[1]), + latitude: serverLocation.latitude, + longitude: serverLocation.longitude + ) + } + + /// given a list of `AnyRelay` values and a name to location mapping, produce a list of + /// `RelayWithLocation`values for those whose locations have successfully been found. + public static func locateRelays( + relays: [T], + locations: [String: REST.ServerLocation] + ) -> [RelayWithLocation] { + relays.compactMap { RelayWithLocation($0, locations: locations) } + } } extension RelayWithLocation: Equatable { diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index ac7b7bc0549e..fe253f586cd4 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -70,6 +70,9 @@ 44DD7D292B7113CA0005F67F /* MockTunnel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44DD7D282B7113CA0005F67F /* MockTunnel.swift */; }; 44DD7D2D2B74E44A0005F67F /* QuantumResistanceSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44DD7D2C2B74E44A0005F67F /* QuantumResistanceSettings.swift */; }; 44DF8AC42BF20BD200869CA4 /* PacketTunnelActor+PostQuantum.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44DF8AC32BF20BD200869CA4 /* PacketTunnelActor+PostQuantum.swift */; }; + 44E1F7582D3EA83A003A60FF /* DestinationDescriber.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44E1F7572D3EA82C003A60FF /* DestinationDescriber.swift */; }; + 44E1F75A2D3FDCCA003A60FF /* DestinationDescriberTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44E1F7592D3FDCBA003A60FF /* DestinationDescriberTests.swift */; }; + 44E1F75B2D3FEC81003A60FF /* DestinationDescriber.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44E1F7572D3EA82C003A60FF /* DestinationDescriber.swift */; }; 5803B4B02940A47300C23744 /* TunnelConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5803B4AF2940A47300C23744 /* TunnelConfiguration.swift */; }; 5803B4B22940A48700C23744 /* TunnelStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5803B4B12940A48700C23744 /* TunnelStore.swift */; }; 5807E2C02432038B00F5FF30 /* String+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5807E2BF2432038B00F5FF30 /* String+Helpers.swift */; }; @@ -1483,6 +1486,8 @@ 44DD7D282B7113CA0005F67F /* MockTunnel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockTunnel.swift; sourceTree = ""; }; 44DD7D2C2B74E44A0005F67F /* QuantumResistanceSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuantumResistanceSettings.swift; sourceTree = ""; }; 44DF8AC32BF20BD200869CA4 /* PacketTunnelActor+PostQuantum.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PacketTunnelActor+PostQuantum.swift"; sourceTree = ""; }; + 44E1F7572D3EA82C003A60FF /* DestinationDescriber.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DestinationDescriber.swift; sourceTree = ""; }; + 44E1F7592D3FDCBA003A60FF /* DestinationDescriberTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DestinationDescriberTests.swift; sourceTree = ""; }; 5802EBC42A8E44AC00E5CE4C /* AppRoutes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRoutes.swift; sourceTree = ""; }; 5802EBC62A8E457A00E5CE4C /* AppRouteProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRouteProtocol.swift; sourceTree = ""; }; 5802EBC82A8E45BA00E5CE4C /* ApplicationRouterDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationRouterDelegate.swift; sourceTree = ""; }; @@ -2664,6 +2669,7 @@ 440E9EFD2BDA982A00B1FD11 /* Tunnel */ = { isa = PBXGroup; children = ( + 44E1F7592D3FDCBA003A60FF /* DestinationDescriberTests.swift */, F09D04BF2AF39D63003D4F89 /* OutgoingConnectionServiceTests.swift */, ); path = Tunnel; @@ -2712,6 +2718,7 @@ 4419AA862D28264D001B13C9 /* ConnectionView */ = { isa = PBXGroup; children = ( + 44E1F7572D3EA82C003A60FF /* DestinationDescriber.swift */, F0ADF1CF2D01B50B00299F09 /* ChipView */, 7AA130972CFF364F00640DF9 /* FeatureIndicators */, 449E9A6E2D283C7400F8574A /* ButtonPanel.swift */, @@ -5623,6 +5630,7 @@ F09D04C12AF39EA2003D4F89 /* OutgoingConnectionService.swift in Sources */, A9A5F9F12ACB05160083449F /* String+Helpers.swift in Sources */, A9A5F9F22ACB05160083449F /* NotificationConfiguration.swift in Sources */, + 44E1F75B2D3FEC81003A60FF /* DestinationDescriber.swift in Sources */, A9A5F9F32ACB05160083449F /* AccountExpirySystemNotificationProvider.swift in Sources */, A9A5F9F52ACB05160083449F /* NewDeviceNotificationProvider.swift in Sources */, F09D04B72AE941DA003D4F89 /* OutgoingConnectionProxyTests.swift in Sources */, @@ -5730,6 +5738,7 @@ F0ACE3362BE517D6006D5333 /* ServerRelaysResponse+Stubs.swift in Sources */, 7ADCB2DA2B6A730400C88F89 /* IPOverrideRepositoryStub.swift in Sources */, A9A5FA312ACB05160083449F /* MockFileCache.swift in Sources */, + 44E1F75A2D3FDCCA003A60FF /* DestinationDescriberTests.swift in Sources */, A9A5FA322ACB05160083449F /* RelayCacheTests.swift in Sources */, A9A5FA332ACB05160083449F /* RelaySelectorTests.swift in Sources */, A9BD4D552CA58C3700C8A0E6 /* RESTTransportStub.swift in Sources */, @@ -6229,6 +6238,7 @@ 58FB865526E8BF3100F188BC /* StorePaymentManagerError.swift in Sources */, F09D04B32AE919AC003D4F89 /* OutgoingConnectionProxy.swift in Sources */, 7A5869BF2B57D0A100640D27 /* IPOverrideStatus.swift in Sources */, + 44E1F7582D3EA83A003A60FF /* DestinationDescriber.swift in Sources */, 58FD5BF42428C67600112C88 /* InAppPurchaseButton.swift in Sources */, 7AF10EB22ADE859200C090B9 /* AlertViewController.swift in Sources */, 587D9676288989DB00CD8F1C /* NSLayoutConstraint+Helpers.swift in Sources */, diff --git a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ConnectionViewComponentPreview.swift b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ConnectionViewComponentPreview.swift index cc24537f13d7..60e963231bfd 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ConnectionViewComponentPreview.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ConnectionViewComponentPreview.swift @@ -7,6 +7,7 @@ // import MullvadMockData +import MullvadREST import MullvadSettings import MullvadTypes import PacketTunnelCore @@ -37,7 +38,10 @@ struct ConnectionViewComponentPreview: View { isDaitaEnabled: true )), state: .connected(SelectedRelaysStub.selectedRelays, isPostQuantum: true, isDaita: true) - ) + ), + relayConstraints: RelayConstraints(), + relayCache: RelayCache(cacheDirectory: ApplicationConfiguration.containerURL), + customListRepository: CustomListRepository() ) var content: (FeatureIndicatorsViewModel, ConnectionViewViewModel, Binding) -> Content diff --git a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ConnectionViewViewModel.swift b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ConnectionViewViewModel.swift index f65982623a4f..9077364664f4 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ConnectionViewViewModel.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ConnectionViewViewModel.swift @@ -7,6 +7,9 @@ // import Combine +import MullvadREST +import MullvadSettings +import MullvadTypes import SwiftUI class ConnectionViewViewModel: ObservableObject { @@ -26,6 +29,17 @@ class ConnectionViewViewModel: ObservableObject { @Published private(set) var tunnelStatus: TunnelStatus @Published var outgoingConnectionInfo: OutgoingConnectionInfo? + @Published var showsActivityIndicator = false + + @Published var relayConstraints: RelayConstraints + let destinationDescriber: DestinationDescribing + + var combinedState: Publishers.CombineLatest< + Published.Publisher, + Published.Publisher + > { + $tunnelStatus.combineLatest($showsActivityIndicator) + } var tunnelIsConnected: Bool { if case .connected = tunnelStatus.state { @@ -35,8 +49,25 @@ class ConnectionViewViewModel: ObservableObject { } } - init(tunnelStatus: TunnelStatus) { + var connectionName: String? { + if case let .only(loc) = relayConstraints.exitLocations { + return destinationDescriber.describe(loc) + } + return nil + } + + init( + tunnelStatus: TunnelStatus, + relayConstraints: RelayConstraints, + relayCache: RelayCacheProtocol, + customListRepository: CustomListRepositoryProtocol + ) { self.tunnelStatus = tunnelStatus + self.relayConstraints = relayConstraints + self.destinationDescriber = DestinationDescriber( + relayCache: relayCache, + customListRepository: customListRepository + ) } func update(tunnelStatus: TunnelStatus) { @@ -131,7 +162,7 @@ extension ConnectionViewViewModel { var localizedTitleForSelectLocationButton: LocalizedStringKey { switch tunnelStatus.state { case .disconnecting, .pendingReconnect, .disconnected, .waitingForConnectivity(.noNetwork): - LocalizedStringKey("Select location") + LocalizedStringKey(connectionName ?? "Select location") case .connecting, .connected, .reconnecting, .waitingForConnectivity(.noConnection), .negotiatingEphemeralPeer, .error: LocalizedStringKey("Switch location") diff --git a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/DestinationDescriber.swift b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/DestinationDescriber.swift new file mode 100644 index 000000000000..1ea8fb929fb0 --- /dev/null +++ b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/DestinationDescriber.swift @@ -0,0 +1,73 @@ +// +// DestinationDescriber.swift +// MullvadVPN +// +// Created by Andrew Bulhak on 2025-01-20. +// Copyright © 2025 Mullvad VPN AB. All rights reserved. +// +// A source of truth for converting an exit relay destination (i.e., a relay or list) into a name + +import MullvadREST +import MullvadSettings +import MullvadTypes + +protocol DestinationDescribing { + func describe(_ destination: UserSelectedRelays) -> String? +} + +struct DestinationDescriber: DestinationDescribing { + let relayCache: RelayCacheProtocol + let customListRepository: CustomListRepositoryProtocol + + public init( + relayCache: RelayCacheProtocol, + customListRepository: CustomListRepositoryProtocol + ) { + self.relayCache = relayCache + self.customListRepository = customListRepository + } + + private func customListDescription(_ destination: UserSelectedRelays) -> String? { + // We only return a description for the list if the user has selected the + // entire list. If they have only selected relays/locations from it, + // we show those as if they selected them from elsewhere. + guard + let customListSelection = destination.customListSelection, + customListSelection.isList, + let customList = customListRepository.fetch(by: customListSelection.listId) + else { return nil } + return customList.name + } + + private func describeRelayLocation( + _ locationSpec: RelayLocation, + usingRelayWithLocation serverLocation: Location + ) -> String { + switch locationSpec { + case .country: serverLocation.country + case .city: serverLocation.city + case let .hostname(_, _, hostname): + "\(serverLocation.city) (\(hostname))" + } + } + + private func relayDescription(_ destination: UserSelectedRelays) -> String? { + guard + let location = destination.locations.first, + let cachedRelays = try? relayCache.read().relays + else { return nil } + let locatedRelays = RelayWithLocation.locateRelays( + relays: cachedRelays.wireguard.relays, + locations: cachedRelays.locations + ) + + guard let matchingRelay = (locatedRelays.first { $0.matches(location: location) + }) else { return nil } + + return describeRelayLocation(location, usingRelayWithLocation: matchingRelay.serverLocation) + } + + func describe(_ destination: UserSelectedRelays) -> String? { + customListDescription(destination) ?? relayDescription(destination) + } +} diff --git a/ios/MullvadVPN/View controllers/Tunnel/TunnelViewController.swift b/ios/MullvadVPN/View controllers/Tunnel/TunnelViewController.swift index 6949e7bc88b4..11e92712161b 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/TunnelViewController.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/TunnelViewController.swift @@ -9,6 +9,7 @@ import Combine import MapKit import MullvadLogging +import MullvadREST import MullvadSettings import MullvadTypes import SwiftUI @@ -60,7 +61,12 @@ class TunnelViewController: UIViewController, RootContainment { self.interactor = interactor tunnelState = interactor.tunnelStatus.state - connectionViewViewModel = ConnectionViewViewModel(tunnelStatus: interactor.tunnelStatus) + connectionViewViewModel = ConnectionViewViewModel( + tunnelStatus: interactor.tunnelStatus, + relayConstraints: interactor.tunnelSettings.relayConstraints, + relayCache: RelayCache(cacheDirectory: ApplicationConfiguration.containerURL), + customListRepository: CustomListRepository() + ) indicatorsViewViewModel = FeatureIndicatorsViewModel( tunnelSettings: interactor.tunnelSettings, ipOverrides: interactor.ipOverrides @@ -97,6 +103,7 @@ class TunnelViewController: UIViewController, RootContainment { interactor.didUpdateTunnelSettings = { [weak self] tunnelSettings in self?.indicatorsViewViewModel.tunnelSettings = tunnelSettings + self?.connectionViewViewModel.relayConstraints = tunnelSettings.relayConstraints } interactor.didUpdateIpOverrides = { [weak self] overrides in diff --git a/ios/MullvadVPNTests/MullvadVPN/View controllers/Tunnel/DestinationDescriberTests.swift b/ios/MullvadVPNTests/MullvadVPN/View controllers/Tunnel/DestinationDescriberTests.swift new file mode 100644 index 000000000000..a33a15366205 --- /dev/null +++ b/ios/MullvadVPNTests/MullvadVPN/View controllers/Tunnel/DestinationDescriberTests.swift @@ -0,0 +1,118 @@ +// +// DestinationDescriberTests.swift +// MullvadVPN +// +// Created by Andrew Bulhak on 2025-01-21. +// Copyright © 2025 Mullvad VPN AB. All rights reserved. +// + +import Foundation +@testable import MullvadREST +@testable import MullvadSettings +import Network +import XCTest + +struct MockRelayCache: RelayCacheProtocol { + func read() throws -> MullvadREST.StoredRelays { + try .init( + cachedRelays: CachedRelays( + relays: ServerRelaysResponseStubs.sampleRelays, + updatedAt: Date() + ) + ) + } + + func readPrebundledRelays() throws -> MullvadREST.StoredRelays { + try self.read() + } + + func write(record: MullvadREST.StoredRelays) throws {} +} + +final class DestinationDescriberTests: XCTestCase { + static let store = InMemorySettingsStore() + override static func setUp() { + SettingsManager.unitTestStore = store + } + + override static func tearDown() { + SettingsManager.unitTestStore = nil + } + + func testDescribeList() throws { + let relayCache = MockRelayCache() + let customListRepository = CustomListRepository() + let describer = DestinationDescriber( + relayCache: relayCache, + customListRepository: customListRepository + ) + let listid = UUID() + try customListRepository.save(list: .init( + id: listid, + name: "NameOfList", + locations: [.country("se"), .country("dk")] + )) + XCTAssertEqual( + describer.describe(.init( + locations: [.country("se"), .country("dk")], + customListSelection: .init(listId: listid, isList: true) + )), + "NameOfList" + ) + } + + func testDescribeSubsetOfList() throws { + let relayCache = MockRelayCache() + let customListRepository = CustomListRepository() + let describer = DestinationDescriber( + relayCache: relayCache, + customListRepository: customListRepository + ) + let listid = UUID() + try customListRepository.save(list: .init( + id: listid, + name: "NameOfList2", + locations: [.country("se"), .country("dk")] + )) + XCTAssertEqual( + describer.describe(.init( + locations: [.country("se")], + customListSelection: .init(listId: listid, isList: false) + )), + "Sweden" + ) + } + + func testDescribeCountryDestination() { + let relayCache = MockRelayCache() + let customListRepository = CustomListRepository() + let describer = DestinationDescriber( + relayCache: relayCache, + customListRepository: customListRepository + ) + XCTAssertEqual(describer.describe(.init(locations: [.country("se")])), "Sweden") + } + + func testDescribeCityDestination() { + let relayCache = MockRelayCache() + let customListRepository = CustomListRepository() + let describer = DestinationDescriber( + relayCache: relayCache, + customListRepository: customListRepository + ) + XCTAssertEqual(describer.describe(.init(locations: [.city("se", "sto")])), "Stockholm") + } + + func testDescribeRelayDestination() { + let relayCache = MockRelayCache() + let customListRepository = CustomListRepository() + let describer = DestinationDescriber( + relayCache: relayCache, + customListRepository: customListRepository + ) + XCTAssertEqual( + describer.describe(.init(locations: [.hostname("se", "sto", "se6-wireguard")])), + "Stockholm (se6-wireguard)" + ) + } +}