summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAndrej Mihajlov <and@mullvad.net>2023-01-10 11:32:48 +0100
committerAndrej Mihajlov <and@mullvad.net>2023-01-10 11:32:48 +0100
commitedc405fe3e1326069a5ba3526e82041ad2e7221a (patch)
treef8562bed4dc5cb346009b0b7400b5fc1e447ff07
parent938177b737b07a5932d87ba67fe7377514619f06 (diff)
parent9188e0f56bbb15a9a1c8d438d3d9439376be0a9c (diff)
downloadmullvadvpn-edc405fe3e1326069a5ba3526e82041ad2e7221a.tar.xz
mullvadvpn-edc405fe3e1326069a5ba3526e82041ad2e7221a.zip
Merge branch 'align-map-marker'
-rw-r--r--ios/BuildInstructions.md2
-rw-r--r--ios/MullvadVPN.xcodeproj/project.pbxproj28
-rw-r--r--ios/MullvadVPN/ConnectContentView.swift264
-rw-r--r--ios/MullvadVPN/ConnectViewController.swift728
-rw-r--r--ios/MullvadVPN/MapViewController.swift273
-rw-r--r--ios/MullvadVPN/SceneDelegate.swift86
-rw-r--r--ios/MullvadVPN/SelectLocationViewController.swift8
-rw-r--r--ios/MullvadVPN/TunnelControlView.swift615
-rw-r--r--ios/MullvadVPN/TunnelManager/TunnelState.swift11
-rw-r--r--ios/MullvadVPN/TunnelViewController.swift210
-rw-r--r--ios/MullvadVPN/TunnelViewControllerInteractor.swift (renamed from ios/MullvadVPN/ConnectInteractor.swift)4
11 files changed, 1174 insertions, 1055 deletions
diff --git a/ios/BuildInstructions.md b/ios/BuildInstructions.md
index ddb8eee941..796d0edbea 100644
--- a/ios/BuildInstructions.md
+++ b/ios/BuildInstructions.md
@@ -231,5 +231,5 @@ Reference: https://docs.travis-ci.com/user/common-build-problems/#mac-macos-sier
The iOS app utilizes SSL pinning. Root certificates can be updated by using the source certificates shipped along with `mullvad-api`:
```
-openssl x509 -in ../mullvad-api/le_root_cert.pem -outform der -out Assets/le_root_cert.cer
+openssl x509 -in ../mullvad-api/le_root_cert.pem -outform der -out MullvadREST/Assets/le_root_cert.cer
```
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj
index decd43171c..b7a1be800f 100644
--- a/ios/MullvadVPN.xcodeproj/project.pbxproj
+++ b/ios/MullvadVPN.xcodeproj/project.pbxproj
@@ -143,7 +143,7 @@
5878A27329091D6D0096FC88 /* TunnelBlockObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5878A27229091D6D0096FC88 /* TunnelBlockObserver.swift */; };
5878A27529093A310096FC88 /* StorePaymentEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5878A27429093A310096FC88 /* StorePaymentEvent.swift */; };
5878A27729093A4F0096FC88 /* StorePaymentBlockObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5878A27629093A4F0096FC88 /* StorePaymentBlockObserver.swift */; };
- 5878A279290954790096FC88 /* ConnectInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5878A278290954790096FC88 /* ConnectInteractor.swift */; };
+ 5878A279290954790096FC88 /* TunnelViewControllerInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5878A278290954790096FC88 /* TunnelViewControllerInteractor.swift */; };
5878A27B2909649A0096FC88 /* CustomOverlayRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5878A27A2909649A0096FC88 /* CustomOverlayRenderer.swift */; };
5878A27D2909657C0096FC88 /* RevokedDeviceInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5878A27C2909657C0096FC88 /* RevokedDeviceInteractor.swift */; };
587988C728A2A01F00E3DF54 /* AccountDataThrottling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587988C628A2A01F00E3DF54 /* AccountDataThrottling.swift */; };
@@ -214,7 +214,7 @@
58B26E282943527300D5980C /* SystemNotificationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58B26E272943527300D5980C /* SystemNotificationProvider.swift */; };
58B26E2A2943545A00D5980C /* NotificationManagerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58B26E292943545A00D5980C /* NotificationManagerDelegate.swift */; };
58B3F30F2742708B00A2DD38 /* HeaderBarButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58B3F30E2742708B00A2DD38 /* HeaderBarButton.swift */; };
- 58B43C1925F77DB60002C8C3 /* ConnectContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58B43C1825F77DB60002C8C3 /* ConnectContentView.swift */; };
+ 58B43C1925F77DB60002C8C3 /* TunnelControlView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58B43C1825F77DB60002C8C3 /* TunnelControlView.swift */; };
58B93A1326C3F13600A55733 /* TunnelState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58B93A1226C3F13600A55733 /* TunnelState.swift */; };
58B993B12608A34500BA7811 /* LoginContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58B993B02608A34500BA7811 /* LoginContentView.swift */; };
58B9EB152489139B00095626 /* DisplayChainedError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58B9EB142489139B00095626 /* DisplayChainedError.swift */; };
@@ -222,8 +222,9 @@
58BFA5C622A7C97F00A6173D /* RelayCacheTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58BFA5C522A7C97F00A6173D /* RelayCacheTracker.swift */; };
58BFA5CC22A7CE1F00A6173D /* ApplicationConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58BFA5CB22A7CE1F00A6173D /* ApplicationConfiguration.swift */; };
58C3A4B222456F1B00340BDB /* AccountInputGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C3A4B122456F1A00340BDB /* AccountInputGroupView.swift */; };
+ 58C3F4F92964B08300D72515 /* MapViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C3F4F82964B08300D72515 /* MapViewController.swift */; };
58CC40EF24A601900019D96E /* ObserverList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CC40EE24A601900019D96E /* ObserverList.swift */; };
- 58CCA010224249A1004F3011 /* ConnectViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CCA00F224249A1004F3011 /* ConnectViewController.swift */; };
+ 58CCA010224249A1004F3011 /* TunnelViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CCA00F224249A1004F3011 /* TunnelViewController.swift */; };
58CCA01222424D11004F3011 /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CCA01122424D11004F3011 /* SettingsViewController.swift */; };
58CCA0162242560B004F3011 /* UIColor+Palette.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CCA0152242560B004F3011 /* UIColor+Palette.swift */; };
58CCA01822426713004F3011 /* AccountViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CCA01722426713004F3011 /* AccountViewController.swift */; };
@@ -741,7 +742,7 @@
5878A27229091D6D0096FC88 /* TunnelBlockObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelBlockObserver.swift; sourceTree = "<group>"; };
5878A27429093A310096FC88 /* StorePaymentEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorePaymentEvent.swift; sourceTree = "<group>"; };
5878A27629093A4F0096FC88 /* StorePaymentBlockObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorePaymentBlockObserver.swift; sourceTree = "<group>"; };
- 5878A278290954790096FC88 /* ConnectInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectInteractor.swift; sourceTree = "<group>"; };
+ 5878A278290954790096FC88 /* TunnelViewControllerInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelViewControllerInteractor.swift; sourceTree = "<group>"; };
5878A27A2909649A0096FC88 /* CustomOverlayRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomOverlayRenderer.swift; sourceTree = "<group>"; };
5878A27C2909657C0096FC88 /* RevokedDeviceInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RevokedDeviceInteractor.swift; sourceTree = "<group>"; };
587988C628A2A01F00E3DF54 /* AccountDataThrottling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDataThrottling.swift; sourceTree = "<group>"; };
@@ -812,7 +813,7 @@
58B26E272943527300D5980C /* SystemNotificationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemNotificationProvider.swift; sourceTree = "<group>"; };
58B26E292943545A00D5980C /* NotificationManagerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManagerDelegate.swift; sourceTree = "<group>"; };
58B3F30E2742708B00A2DD38 /* HeaderBarButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderBarButton.swift; sourceTree = "<group>"; };
- 58B43C1825F77DB60002C8C3 /* ConnectContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectContentView.swift; sourceTree = "<group>"; };
+ 58B43C1825F77DB60002C8C3 /* TunnelControlView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelControlView.swift; sourceTree = "<group>"; };
58B93A1226C3F13600A55733 /* TunnelState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelState.swift; sourceTree = "<group>"; };
58B993B02608A34500BA7811 /* LoginContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginContentView.swift; sourceTree = "<group>"; };
58B9EB122488ED2100095626 /* AlertPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertPresenter.swift; sourceTree = "<group>"; };
@@ -821,8 +822,9 @@
58BFA5C522A7C97F00A6173D /* RelayCacheTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayCacheTracker.swift; sourceTree = "<group>"; };
58BFA5CB22A7CE1F00A6173D /* ApplicationConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationConfiguration.swift; sourceTree = "<group>"; };
58C3A4B122456F1A00340BDB /* AccountInputGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountInputGroupView.swift; sourceTree = "<group>"; };
+ 58C3F4F82964B08300D72515 /* MapViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapViewController.swift; sourceTree = "<group>"; };
58CC40EE24A601900019D96E /* ObserverList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObserverList.swift; sourceTree = "<group>"; };
- 58CCA00F224249A1004F3011 /* ConnectViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectViewController.swift; sourceTree = "<group>"; };
+ 58CCA00F224249A1004F3011 /* TunnelViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelViewController.swift; sourceTree = "<group>"; };
58CCA01122424D11004F3011 /* SettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewController.swift; sourceTree = "<group>"; };
58CCA0152242560B004F3011 /* UIColor+Palette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+Palette.swift"; sourceTree = "<group>"; };
58CCA01722426713004F3011 /* AccountViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountViewController.swift; sourceTree = "<group>"; };
@@ -1382,11 +1384,12 @@
5891BF1B25E3E3EB006D6FB0 /* Bundle+ProductVersion.swift */,
587EB669270EFACB00123C75 /* CharacterSet+IPAddress.swift */,
58E511E528DDDEAC00B0BCDE /* CodingErrors+CustomErrorDescription.swift */,
- 58B43C1825F77DB60002C8C3 /* ConnectContentView.swift */,
+ 58B43C1825F77DB60002C8C3 /* TunnelControlView.swift */,
+ 58C3F4F82964B08300D72515 /* MapViewController.swift */,
58A1AA8B23F5584B009F7EA6 /* ConnectionPanelView.swift */,
- 58CCA00F224249A1004F3011 /* ConnectViewController.swift */,
+ 58CCA00F224249A1004F3011 /* TunnelViewController.swift */,
5878A27A2909649A0096FC88 /* CustomOverlayRenderer.swift */,
- 5878A278290954790096FC88 /* ConnectInteractor.swift */,
+ 5878A278290954790096FC88 /* TunnelViewControllerInteractor.swift */,
5871FB95254ADE4E0051A0A4 /* ConsolidatedApplicationLog.swift */,
5896AE83246D5889005B36CB /* CustomDateComponentsFormatting.swift */,
582BB1B0229569620055B6EF /* CustomNavigationBar.swift */,
@@ -2288,6 +2291,7 @@
5803B4B02940A47300C23744 /* TunnelConfiguration.swift in Sources */,
587EB672271451E300123C75 /* PreferencesViewModel.swift in Sources */,
586A950C290125EE007BAF2B /* AlertPresenter.swift in Sources */,
+ 58C3F4F92964B08300D72515 /* MapViewController.swift in Sources */,
584D26C6270C8741004EA533 /* SettingsDNSTextCell.swift in Sources */,
58F2E148276A307400A79513 /* MapConnectionStatusOperation.swift in Sources */,
58BA693123EADA6A009DC256 /* SimulatorTunnelProvider.swift in Sources */,
@@ -2305,7 +2309,7 @@
58F8AC0E25D3F8CE002BE0ED /* ProblemReportReviewViewController.swift in Sources */,
5878A27129091CF20096FC88 /* AccountInteractor.swift in Sources */,
068CE5742927B7A400A068BB /* Migration.swift in Sources */,
- 58CCA010224249A1004F3011 /* ConnectViewController.swift in Sources */,
+ 58CCA010224249A1004F3011 /* TunnelViewController.swift in Sources */,
58B26E22294351EA00D5980C /* InAppNotificationProvider.swift in Sources */,
5893716A28817A45004EE76C /* DeviceManagementViewController.swift in Sources */,
58BFA5C622A7C97F00A6173D /* RelayCacheTracker.swift in Sources */,
@@ -2330,7 +2334,7 @@
E1187ABC289BBB850024E748 /* OutOfTimeViewController.swift in Sources */,
58293FB125124117005D0BB5 /* CustomTextField.swift in Sources */,
58138E61294871C600684F0C /* DeviceDataThrottling.swift in Sources */,
- 5878A279290954790096FC88 /* ConnectInteractor.swift in Sources */,
+ 5878A279290954790096FC88 /* TunnelViewControllerInteractor.swift in Sources */,
582AE3102440A6CA00E6733A /* AccountTokenInput.swift in Sources */,
5820EDAB288FF0D2006BF4E4 /* DeviceRowView.swift in Sources */,
5846227726E22A7C0035F7C2 /* StorePaymentManagerDelegate.swift in Sources */,
@@ -2427,7 +2431,7 @@
58E20771274672CA00DE5D77 /* LaunchViewController.swift in Sources */,
584D26C4270C855B004EA533 /* PreferencesDataSource.swift in Sources */,
58FD5BF024238EB300112C88 /* SKProduct+Formatting.swift in Sources */,
- 58B43C1925F77DB60002C8C3 /* ConnectContentView.swift in Sources */,
+ 58B43C1925F77DB60002C8C3 /* TunnelControlView.swift in Sources */,
5811DE50239014550011EB53 /* NEVPNStatus+Debug.swift in Sources */,
58C3A4B222456F1B00340BDB /* AccountInputGroupView.swift in Sources */,
58ACF64B26553C3F00ACE4B7 /* SettingsSwitchCell.swift in Sources */,
diff --git a/ios/MullvadVPN/ConnectContentView.swift b/ios/MullvadVPN/ConnectContentView.swift
deleted file mode 100644
index faa54413b7..0000000000
--- a/ios/MullvadVPN/ConnectContentView.swift
+++ /dev/null
@@ -1,264 +0,0 @@
-//
-// ConnectContentView.swift
-// MullvadVPN
-//
-// Created by pronebird on 09/03/2021.
-// Copyright © 2021 Mullvad VPN AB. All rights reserved.
-//
-
-import MapKit
-import UIKit
-
-class ConnectContentView: UIView {
- enum ActionButton {
- case connect
- case disconnect
- case cancel
- case selectLocation
- }
-
- lazy var mapView: MKMapView = {
- let mapView = MKMapView()
- mapView.translatesAutoresizingMaskIntoConstraints = true
- mapView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
- mapView.showsUserLocation = false
- mapView.isZoomEnabled = false
- mapView.isScrollEnabled = false
- mapView.isUserInteractionEnabled = false
- mapView.accessibilityElementsHidden = true
- return mapView
- }()
-
- let secureLabel = makeBoldTextLabel(ofSize: 20)
- let cityLabel = makeBoldTextLabel(ofSize: 34)
- let countryLabel = makeBoldTextLabel(ofSize: 34)
-
- let activityIndicator: SpinnerActivityIndicatorView = {
- let activityIndicator = SpinnerActivityIndicatorView(style: .large)
- activityIndicator.translatesAutoresizingMaskIntoConstraints = false
- activityIndicator.tintColor = .white
- activityIndicator.setContentHuggingPriority(.defaultHigh, for: .horizontal)
- activityIndicator.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
- return activityIndicator
- }()
-
- let locationContainerView: UIView = {
- let view = UIView()
- view.translatesAutoresizingMaskIntoConstraints = false
- view.isAccessibilityElement = true
- view.accessibilityTraits = .summaryElement
- return view
- }()
-
- lazy var connectionPanel: ConnectionPanelView = {
- let view = ConnectionPanelView()
- view.translatesAutoresizingMaskIntoConstraints = false
- return view
- }()
-
- lazy var buttonsStackView: UIStackView = {
- let stackView = UIStackView()
- stackView.spacing = UIMetrics.interButtonSpacing
- stackView.axis = .vertical
- stackView.translatesAutoresizingMaskIntoConstraints = false
- return stackView
- }()
-
- lazy var connectButton: AppButton = {
- let button = AppButton(style: .success)
- button.translatesAutoresizingMaskIntoConstraints = false
- return button
- }()
-
- lazy var cancelButton: AppButton = {
- let button = AppButton(style: .translucentDanger)
- button.accessibilityIdentifier = "CancelButton"
- button.translatesAutoresizingMaskIntoConstraints = false
- return button
- }()
-
- lazy var selectLocationButton: AppButton = {
- let button = AppButton(style: .translucentNeutral)
- button.accessibilityIdentifier = "SelectLocationButton"
- button.translatesAutoresizingMaskIntoConstraints = false
- return button
- }()
-
- lazy var selectLocationBlurView = TranslucentButtonBlurView(button: selectLocationButton)
-
- lazy var cancelButtonBlurView = TranslucentButtonBlurView(button: cancelButton)
-
- let splitDisconnectButton: DisconnectSplitButton = {
- let button = DisconnectSplitButton()
- button.primaryButton.accessibilityIdentifier = "DisconnectButton"
- button.translatesAutoresizingMaskIntoConstraints = false
- return button
- }()
-
- let containerView: UIView = {
- let view = UIView()
- view.translatesAutoresizingMaskIntoConstraints = false
- return view
- }()
-
- private var traitConstraints = [NSLayoutConstraint]()
-
- override init(frame: CGRect) {
- super.init(frame: frame)
-
- backgroundColor = .primaryColor
- layoutMargins = UIMetrics.contentLayoutMargins
- accessibilityContainerType = .semanticGroup
-
- addSubviews()
- }
-
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
-
- func setActionButtons(_ actionButtons: [ActionButton]) {
- let views = actionButtons.map { self.view(forActionButton: $0) }
-
- setArrangedButtons(views)
- }
-
- private class func makeBoldTextLabel(ofSize fontSize: CGFloat) -> UILabel {
- let textLabel = UILabel()
- textLabel.translatesAutoresizingMaskIntoConstraints = false
- textLabel.font = UIFont.boldSystemFont(ofSize: fontSize)
- textLabel.textColor = .white
- return textLabel
- }
-
- private func addSubviews() {
- mapView.frame = bounds
-
- locationContainerView.addSubview(secureLabel)
- locationContainerView.addSubview(cityLabel)
- locationContainerView.addSubview(countryLabel)
-
- containerView.addSubview(activityIndicator)
- containerView.addSubview(locationContainerView)
- containerView.addSubview(connectionPanel)
- containerView.addSubview(buttonsStackView)
-
- addSubview(mapView)
- addSubview(containerView)
-
- NSLayoutConstraint.activate([
- containerView.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor),
- containerView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor),
- containerView.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor),
-
- locationContainerView.topAnchor
- .constraint(greaterThanOrEqualTo: containerView.topAnchor),
- locationContainerView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
- locationContainerView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
-
- activityIndicator.centerXAnchor.constraint(equalTo: mapView.centerXAnchor),
- locationContainerView.topAnchor.constraint(
- equalTo: activityIndicator.bottomAnchor,
- constant: 22
- ),
-
- secureLabel.topAnchor.constraint(equalTo: locationContainerView.topAnchor),
- secureLabel.leadingAnchor.constraint(equalTo: locationContainerView.leadingAnchor),
- secureLabel.trailingAnchor.constraint(equalTo: locationContainerView.trailingAnchor),
-
- cityLabel.topAnchor.constraint(equalTo: secureLabel.bottomAnchor, constant: 8),
- cityLabel.leadingAnchor.constraint(equalTo: locationContainerView.leadingAnchor),
- cityLabel.trailingAnchor.constraint(equalTo: locationContainerView.trailingAnchor),
-
- countryLabel.topAnchor.constraint(equalTo: cityLabel.bottomAnchor, constant: 8),
- countryLabel.leadingAnchor.constraint(equalTo: locationContainerView.leadingAnchor),
- countryLabel.trailingAnchor.constraint(equalTo: locationContainerView.trailingAnchor),
- countryLabel.bottomAnchor.constraint(equalTo: locationContainerView.bottomAnchor),
-
- connectionPanel.topAnchor.constraint(
- equalTo: locationContainerView.bottomAnchor,
- constant: 8
- ),
- connectionPanel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
- connectionPanel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
-
- buttonsStackView.topAnchor.constraint(
- equalTo: connectionPanel.bottomAnchor,
- constant: 24
- ),
- buttonsStackView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
- buttonsStackView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
- buttonsStackView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
- ])
-
- updateTraitConstraints()
- }
-
- private func updateTraitConstraints() {
- var layoutConstraints = [NSLayoutConstraint]()
-
- switch traitCollection.userInterfaceIdiom {
- case .pad:
- // Max container width is 70% width of iPad in portrait mode
- let maxWidth = min(
- UIScreen.main.nativeBounds.width * 0.7,
- UIMetrics.maximumSplitViewContentContainerWidth
- )
-
- layoutConstraints.append(contentsOf: [
- containerView.trailingAnchor
- .constraint(lessThanOrEqualTo: layoutMarginsGuide.trailingAnchor),
- containerView.widthAnchor.constraint(equalToConstant: maxWidth)
- .withPriority(.defaultHigh),
- ])
-
- case .phone:
- layoutConstraints
- .append(
- containerView.trailingAnchor
- .constraint(equalTo: layoutMarginsGuide.trailingAnchor)
- )
-
- default:
- break
- }
-
- traitConstraints = layoutConstraints
- removeConstraints(traitConstraints)
- NSLayoutConstraint.activate(layoutConstraints)
- }
-
- override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
- super.traitCollectionDidChange(previousTraitCollection)
-
- if traitCollection.userInterfaceIdiom != previousTraitCollection?.userInterfaceIdiom {
- updateTraitConstraints()
- }
- }
-
- private func setArrangedButtons(_ newButtons: [UIView]) {
- buttonsStackView.arrangedSubviews.forEach { button in
- if !newButtons.contains(button) {
- buttonsStackView.removeArrangedSubview(button)
- button.removeFromSuperview()
- }
- }
-
- newButtons.forEach { button in
- buttonsStackView.addArrangedSubview(button)
- }
- }
-
- private func view(forActionButton actionButton: ActionButton) -> UIView {
- switch actionButton {
- case .connect:
- return connectButton
- case .disconnect:
- return splitDisconnectButton
- case .cancel:
- return cancelButtonBlurView
- case .selectLocation:
- return selectLocationBlurView
- }
- }
-}
diff --git a/ios/MullvadVPN/ConnectViewController.swift b/ios/MullvadVPN/ConnectViewController.swift
deleted file mode 100644
index f87ef7680a..0000000000
--- a/ios/MullvadVPN/ConnectViewController.swift
+++ /dev/null
@@ -1,728 +0,0 @@
-//
-// ConnectViewController.swift
-// MullvadVPN
-//
-// Created by pronebird on 20/03/2019.
-// Copyright © 2019 Mullvad VPN AB. All rights reserved.
-//
-
-import MapKit
-import MullvadLogging
-import MullvadTypes
-import TunnelProviderMessaging
-import UIKit
-
-protocol ConnectViewControllerDelegate: AnyObject {
- func connectViewControllerShouldShowSelectLocationPicker(_ controller: ConnectViewController)
-}
-
-class ConnectViewController: UIViewController, MKMapViewDelegate, RootContainment {
- private static let geoJSONSourceFileName = "countries.geo.json"
- private static let locationMarkerReuseIdentifier = "location"
-
- private let interactor: ConnectInteractor
-
- weak var delegate: ConnectViewControllerDelegate?
-
- let notificationController = NotificationController()
-
- private let contentView: ConnectContentView = {
- let view = ConnectContentView(frame: UIScreen.main.bounds)
- view.translatesAutoresizingMaskIntoConstraints = false
- return view
- }()
-
- private let logger = Logger(label: "ConnectViewController")
-
- private var targetRegion: MKCoordinateRegion?
- private let locationMarker = MKPointAnnotation()
-
- private var isAnimatingMap = false
- private var mapRegionAnimationDidEnd: (() -> Void)?
-
- override var preferredStatusBarStyle: UIStatusBarStyle {
- return .lightContent
- }
-
- var preferredHeaderBarPresentation: HeaderBarPresentation {
- switch interactor.deviceState {
- case .loggedIn, .revoked:
- return HeaderBarPresentation(
- style: tunnelState.isSecured ? .secured : .unsecured,
- showsDivider: false
- )
- case .loggedOut:
- return HeaderBarPresentation(style: .default, showsDivider: true)
- }
- }
-
- var prefersHeaderBarHidden: Bool {
- return false
- }
-
- private var tunnelState: TunnelState = .disconnected {
- didSet {
- setNeedsHeaderBarStyleAppearanceUpdate()
- updateTunnelRelay()
- updateUserInterfaceForTunnelStateChange()
-
- // Avoid unnecessary animations, particularly when this property is changed from inside
- // the `viewDidLoad`.
- let isViewVisible = viewIfLoaded?.window != nil
-
- updateLocation(animated: isViewVisible)
- }
- }
-
- init(interactor: ConnectInteractor) {
- self.interactor = interactor
-
- super.init(nibName: nil, bundle: nil)
- }
-
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
-
- override func viewDidLoad() {
- super.viewDidLoad()
-
- contentView.connectButton.addTarget(
- self,
- action: #selector(handleConnect(_:)),
- for: .touchUpInside
- )
- contentView.cancelButton.addTarget(
- self,
- action: #selector(handleDisconnect(_:)),
- for: .touchUpInside
- )
- contentView.splitDisconnectButton.primaryButton.addTarget(
- self,
- action: #selector(handleDisconnect(_:)),
- for: .touchUpInside
- )
- contentView.splitDisconnectButton.secondaryButton.addTarget(
- self,
- action: #selector(handleReconnect(_:)),
- for: .touchUpInside
- )
-
- contentView.selectLocationButton.addTarget(
- self,
- action: #selector(handleSelectLocation(_:)),
- for: .touchUpInside
- )
-
- interactor.didUpdateDeviceState = { [weak self] deviceState in
- self?.setNeedsHeaderBarStyleAppearanceUpdate()
- }
-
- interactor.didUpdateTunnelStatus = { [weak self] tunnelStatus in
- self?.tunnelState = tunnelStatus.state
- }
-
- tunnelState = interactor.tunnelStatus.state
-
- addSubviews()
- setupMapView()
- updateLocation(animated: false)
- addNotificationController()
- }
-
- override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
- super.traitCollectionDidChange(previousTraitCollection)
-
- if previousTraitCollection?.userInterfaceIdiom != traitCollection.userInterfaceIdiom ||
- previousTraitCollection?.horizontalSizeClass != traitCollection.horizontalSizeClass
- {
- updateTraitDependentViews()
- }
- }
-
- func setMainContentHidden(_ isHidden: Bool, animated: Bool) {
- let actions = {
- self.contentView.containerView.alpha = isHidden ? 0 : 1
- }
-
- if animated {
- UIView.animate(withDuration: 0.25, animations: actions)
- } else {
- actions()
- }
- }
-
- private func addSubviews() {
- view.addSubview(contentView)
-
- NSLayoutConstraint.activate([
- contentView.topAnchor.constraint(equalTo: view.topAnchor),
- contentView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
- contentView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
- contentView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
- ])
-
- // Force layout since we rely on view frames when positioning map camera.
- view.layoutIfNeeded()
- }
-
- override func viewWillTransition(
- to size: CGSize,
- with coordinator: UIViewControllerTransitionCoordinator
- ) {
- super.viewWillTransition(to: size, with: coordinator)
-
- coordinator.animate(alongsideTransition: { _ in }, completion: { context in
- self.updateLocation(animated: context.isAnimated)
- })
- }
-
- // MARK: - Private
-
- private func updateUserInterfaceForTunnelStateChange() {
- contentView.secureLabel.text = tunnelState.localizedTitleForSecureLabel.uppercased()
- contentView.secureLabel.textColor = tunnelState.textColorForSecureLabel
-
- contentView.connectButton.setTitle(
- NSLocalizedString(
- "CONNECT_BUTTON_TITLE",
- tableName: "Main",
- value: "Secure connection",
- comment: ""
- ), for: .normal
- )
- contentView.selectLocationButton.setTitle(
- tunnelState.localizedTitleForSelectLocationButton,
- for: .normal
- )
- contentView.cancelButton.setTitle(
- NSLocalizedString(
- "CANCEL_BUTTON_TITLE",
- tableName: "Main",
- value: "Cancel",
- comment: ""
- ), for: .normal
- )
- contentView.splitDisconnectButton.primaryButton.setTitle(
- NSLocalizedString(
- "DISCONNECT_BUTTON_TITLE",
- tableName: "Main",
- value: "Disconnect",
- comment: ""
- ), for: .normal
- )
- contentView.splitDisconnectButton.secondaryButton.accessibilityLabel = NSLocalizedString(
- "RECONNECT_BUTTON_ACCESSIBILITY_LABEL",
- tableName: "Main",
- value: "Reconnect",
- comment: ""
- )
-
- updateTraitDependentViews()
- }
-
- private func updateTraitDependentViews() {
- contentView.setActionButtons(tunnelState.actionButtons(traitCollection: traitCollection))
- }
-
- private func attributedStringForLocation(string: String) -> NSAttributedString {
- let paragraphStyle = NSMutableParagraphStyle()
- paragraphStyle.lineSpacing = 0
- paragraphStyle.lineHeightMultiple = 0.80
- return NSAttributedString(string: string, attributes: [
- .paragraphStyle: paragraphStyle,
- ])
- }
-
- private func updateTunnelRelay() {
- switch tunnelState {
- case let .connecting(tunnelRelay):
- setTunnelRelay(tunnelRelay)
-
- case let .connected(tunnelRelay), let .reconnecting(tunnelRelay):
- setTunnelRelay(tunnelRelay)
-
- case .disconnected, .disconnecting, .pendingReconnect, .waitingForConnectivity:
- setTunnelRelay(nil)
- }
-
- contentView.locationContainerView.accessibilityLabel = tunnelState
- .localizedAccessibilityLabel
- }
-
- private func setTunnelRelay(_ tunnelRelay: PacketTunnelRelay?) {
- if let tunnelRelay = tunnelRelay {
- contentView.cityLabel
- .attributedText = attributedStringForLocation(string: tunnelRelay.location.city)
- contentView.countryLabel
- .attributedText = attributedStringForLocation(string: tunnelRelay.location.country)
-
- contentView.connectionPanel.dataSource = ConnectionPanelData(
- inAddress: "\(tunnelRelay.ipv4Relay) UDP",
- outAddress: nil
- )
- contentView.connectionPanel.isHidden = false
- contentView.connectionPanel.connectedRelayName = tunnelRelay.hostname
- } else {
- contentView.countryLabel.attributedText = attributedStringForLocation(string: " ")
- contentView.cityLabel.attributedText = attributedStringForLocation(string: " ")
- contentView.connectionPanel.dataSource = nil
- contentView.connectionPanel.isHidden = true
- }
- }
-
- private func locationMarkerOffset() -> CGPoint {
- // Compute the activity indicator frame within the view coordinate system.
- let activityIndicatorFrame = contentView.activityIndicator.convert(
- contentView.activityIndicator.bounds,
- to: view
- )
-
- // Compute the offset to align the marker on the map with activity indicator.
- let offsetY = activityIndicatorFrame.midY - contentView.mapView.frame.midY
-
- return CGPoint(x: 0, y: offsetY)
- }
-
- private func computeCoordinateRegion(
- center: CLLocationCoordinate2D,
- offset: CGPoint
- ) -> MKCoordinateRegion {
- let span = MKCoordinateSpan(latitudeDelta: 30, longitudeDelta: 30)
- var region = contentView.mapView
- .regionThatFits(MKCoordinateRegion(center: center, span: span))
-
- let latitudeDeltaPerPoint = region.span.latitudeDelta / contentView.mapView.frame.height
- region.center = center
- region.center.latitude += CLLocationDegrees(latitudeDeltaPerPoint * offset.y)
-
- return contentView.mapView.regionThatFits(region)
- }
-
- private func updateLocation(animated: Bool) {
- switch tunnelState {
- case let .connecting(tunnelRelay):
- removeLocationMarker()
- contentView.activityIndicator.startAnimating()
-
- if let tunnelRelay = tunnelRelay {
- setLocation(coordinate: tunnelRelay.location.geoCoordinate, animated: animated)
- } else {
- unsetLocation(animated: animated)
- }
-
- case let .reconnecting(tunnelRelay):
- removeLocationMarker()
- contentView.activityIndicator.startAnimating()
-
- setLocation(coordinate: tunnelRelay.location.geoCoordinate, animated: animated)
-
- case let .connected(tunnelRelay):
- // Show marker right away if activity indicator is not animating, i.e when the app
- // launches with connected tunnel.
- let showMarkerRightAway = !contentView.activityIndicator.isAnimating
-
- if showMarkerRightAway {
- addLocationMarker(coordinate: tunnelRelay.location.geoCoordinate)
- }
-
- setLocation(
- coordinate: tunnelRelay.location.geoCoordinate,
- animated: animated
- ) { [weak self] in
- if !showMarkerRightAway {
- self?.contentView.activityIndicator.stopAnimating()
- self?.addLocationMarker(coordinate: tunnelRelay.location.geoCoordinate)
- }
- }
-
- case .pendingReconnect:
- removeLocationMarker()
- contentView.activityIndicator.startAnimating()
-
- case .waitingForConnectivity:
- removeLocationMarker()
-
- case .disconnected, .disconnecting:
- removeLocationMarker()
- contentView.activityIndicator.stopAnimating()
-
- unsetLocation(animated: animated)
- }
- }
-
- private func addLocationMarker(coordinate: CLLocationCoordinate2D) {
- locationMarker.coordinate = coordinate
- contentView.mapView.addAnnotation(locationMarker)
- }
-
- private func removeLocationMarker() {
- contentView.mapView.removeAnnotation(locationMarker)
- }
-
- private func setLocation(
- coordinate: CLLocationCoordinate2D,
- animated: Bool,
- animationDidEnd: (() -> Void)? = nil
- ) {
- let markerOffset = locationMarkerOffset()
- let region = computeCoordinateRegion(center: coordinate, offset: markerOffset)
-
- if let targetRegion = targetRegion, targetRegion.isApproximatelyEqualTo(region) {
- if isAnimatingMap {
- mapRegionAnimationDidEnd = animationDidEnd
- } else {
- animationDidEnd?()
- }
- } else {
- mapRegionAnimationDidEnd = animationDidEnd
- setMapRegion(region, animated: animated)
- }
- }
-
- private func unsetLocation(animated: Bool) {
- let span = MKCoordinateSpan(latitudeDelta: 90, longitudeDelta: 90)
- let coordinate = CLLocationCoordinate2D(latitude: 0, longitude: 0)
- let region = contentView.mapView.regionThatFits(
- MKCoordinateRegion(center: coordinate, span: span)
- )
-
- mapRegionAnimationDidEnd = nil
-
- if let targetRegion = targetRegion, targetRegion.isApproximatelyEqualTo(region) {
- return
- } else {
- setMapRegion(region, animated: animated)
- }
- }
-
- private func setMapRegion(_ region: MKCoordinateRegion, animated: Bool) {
- isAnimatingMap = true
- targetRegion = region
- contentView.mapView.setRegion(region, animated: animated)
- }
-
- private func addNotificationController() {
- let notificationView = notificationController.view!
- notificationView.translatesAutoresizingMaskIntoConstraints = false
-
- addChild(notificationController)
- view.addSubview(notificationView)
- notificationController.didMove(toParent: self)
-
- NSLayoutConstraint.activate([
- notificationView.topAnchor.constraint(equalTo: view.topAnchor),
- notificationView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
- notificationView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
- notificationView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
- ])
- }
-
- // MARK: - Actions
-
- @objc func handleConnect(_ sender: Any) {
- interactor.startTunnel()
- }
-
- @objc func handleDisconnect(_ sender: Any) {
- interactor.stopTunnel()
- }
-
- @objc func handleReconnect(_ sender: Any) {
- interactor.reconnectTunnel(selectNewRelay: true)
- }
-
- @objc func handleSelectLocation(_ sender: Any) {
- delegate?.connectViewControllerShouldShowSelectLocationPicker(self)
- }
-
- // MARK: - MKMapViewDelegate
-
- func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
- if let polygon = overlay as? MKPolygon {
- let renderer = MKPolygonRenderer(polygon: polygon)
- renderer.fillColor = .primaryColor
- renderer.strokeColor = .secondaryColor
- renderer.lineWidth = 1
- renderer.lineCap = .round
- renderer.lineJoin = .round
- return renderer
- }
-
- if let tileOverlay = overlay as? MKTileOverlay {
- return CustomOverlayRenderer(overlay: tileOverlay)
- }
-
- return MKOverlayRenderer()
- }
-
- func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
- if annotation === locationMarker {
- let view = mapView.dequeueReusableAnnotationView(
- withIdentifier: Self.locationMarkerReuseIdentifier,
- for: annotation
- )
- view.isDraggable = false
- view.canShowCallout = false
- view.image = UIImage(named: "LocationMarkerSecure")
- return view
- }
- return nil
- }
-
- func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) {
- mapRegionAnimationDidEnd?()
- mapRegionAnimationDidEnd = nil
- isAnimatingMap = false
- }
-
- // MARK: - Private
-
- private func setupMapView() {
- contentView.mapView.insetsLayoutMarginsFromSafeArea = false
- contentView.mapView.delegate = self
- contentView.mapView.register(
- MKAnnotationView.self,
- forAnnotationViewWithReuseIdentifier: Self.locationMarkerReuseIdentifier
- )
-
- // Use dark style for the map to dim the map grid
- contentView.mapView.overrideUserInterfaceStyle = .dark
-
- addTileOverlay()
- loadGeoJSONData()
- }
-
- private func addTileOverlay() {
- // Use `nil` for template URL to make sure that Apple maps do not load
- // tiles from remote.
- let tileOverlay = MKTileOverlay(urlTemplate: nil)
-
- // Replace the default map tiles
- tileOverlay.canReplaceMapContent = true
-
- contentView.mapView.addOverlay(tileOverlay, level: .aboveLabels)
- }
-
- private func loadGeoJSONData() {
- guard let fileURL = Bundle.main.url(
- forResource: Self.geoJSONSourceFileName,
- withExtension: nil
- ) else {
- logger.debug("Failed to locate \(Self.geoJSONSourceFileName) in main bundle.")
- return
- }
-
- do {
- let data = try Data(contentsOf: fileURL)
- let overlays = try GeoJSON.decodeGeoJSON(data)
-
- contentView.mapView.addOverlays(overlays, level: .aboveLabels)
- } catch {
- logger.error(error: error, message: "Failed to load geojson.")
- }
- }
-}
-
-private extension TunnelState {
- var textColorForSecureLabel: UIColor {
- switch self {
- case .connecting, .reconnecting, .waitingForConnectivity:
- return .white
-
- case .connected:
- return .successColor
-
- case .disconnecting, .disconnected, .pendingReconnect:
- return .dangerColor
- }
- }
-
- var localizedTitleForSecureLabel: String {
- switch self {
- case .connecting, .reconnecting:
- return NSLocalizedString(
- "TUNNEL_STATE_CONNECTING",
- tableName: "Main",
- value: "Creating secure connection",
- comment: ""
- )
-
- case .connected:
- return NSLocalizedString(
- "TUNNEL_STATE_CONNECTED",
- tableName: "Main",
- value: "Secure connection",
- comment: ""
- )
-
- case .disconnecting(.nothing):
- return NSLocalizedString(
- "TUNNEL_STATE_DISCONNECTING",
- tableName: "Main",
- value: "Disconnecting",
- comment: ""
- )
- case .disconnecting(.reconnect), .pendingReconnect:
- return NSLocalizedString(
- "TUNNEL_STATE_PENDING_RECONNECT",
- tableName: "Main",
- value: "Reconnecting",
- comment: ""
- )
-
- case .disconnected:
- return NSLocalizedString(
- "TUNNEL_STATE_DISCONNECTED",
- tableName: "Main",
- value: "Unsecured connection",
- comment: ""
- )
-
- case .waitingForConnectivity:
- return NSLocalizedString(
- "TUNNEL_STATE_WAITING_FOR_CONNECTIVITY",
- tableName: "Main",
- value: "Blocked connection",
- comment: ""
- )
- }
- }
-
- var localizedTitleForSelectLocationButton: String? {
- switch self {
- case .disconnecting(.reconnect), .pendingReconnect:
- return NSLocalizedString(
- "SWITCH_LOCATION_BUTTON_TITLE",
- tableName: "Main",
- value: "Select location",
- comment: ""
- )
-
- case .disconnected, .disconnecting(.nothing):
- return NSLocalizedString(
- "SELECT_LOCATION_BUTTON_TITLE",
- tableName: "Main",
- value: "Select location",
- comment: ""
- )
- case .connecting, .connected, .reconnecting, .waitingForConnectivity:
- return NSLocalizedString(
- "SWITCH_LOCATION_BUTTON_TITLE",
- tableName: "Main",
- value: "Switch location",
- comment: ""
- )
- }
- }
-
- var localizedAccessibilityLabel: String {
- switch self {
- case .connecting:
- return NSLocalizedString(
- "TUNNEL_STATE_CONNECTING_ACCESSIBILITY_LABEL",
- tableName: "Main",
- value: "Creating secure connection",
- comment: ""
- )
-
- case let .connected(tunnelInfo):
- return String(
- format: NSLocalizedString(
- "TUNNEL_STATE_CONNECTED_ACCESSIBILITY_LABEL",
- tableName: "Main",
- value: "Secure connection. Connected to %@, %@",
- comment: ""
- ),
- tunnelInfo.location.city,
- tunnelInfo.location.country
- )
-
- case .disconnected:
- return NSLocalizedString(
- "TUNNEL_STATE_DISCONNECTED_ACCESSIBILITY_LABEL",
- tableName: "Main",
- value: "Unsecured connection",
- comment: ""
- )
-
- case let .reconnecting(tunnelInfo):
- return String(
- format: NSLocalizedString(
- "TUNNEL_STATE_RECONNECTING_ACCESSIBILITY_LABEL",
- tableName: "Main",
- value: "Reconnecting to %@, %@",
- comment: ""
- ),
- tunnelInfo.location.city,
- tunnelInfo.location.country
- )
-
- case .waitingForConnectivity:
- return NSLocalizedString(
- "TUNNEL_STATE_WAITING_FOR_CONNECTIVITY_ACCESSIBILITY_LABEL",
- tableName: "Main",
- value: "Blocked connection",
- comment: ""
- )
-
- case .disconnecting(.nothing):
- return NSLocalizedString(
- "TUNNEL_STATE_DISCONNECTING_ACCESSIBILITY_LABEL",
- tableName: "Main",
- value: "Disconnecting",
- comment: ""
- )
-
- case .disconnecting(.reconnect), .pendingReconnect:
- return NSLocalizedString(
- "TUNNEL_STATE_PENDING_RECONNECT_ACCESSIBILITY_LABEL",
- tableName: "Main",
- value: "Reconnecting",
- comment: ""
- )
- }
- }
-
- func actionButtons(traitCollection: UITraitCollection) -> [ConnectContentView.ActionButton] {
- switch (traitCollection.userInterfaceIdiom, traitCollection.horizontalSizeClass) {
- case (.phone, _), (.pad, .compact):
- switch self {
- case .disconnected, .disconnecting(.nothing):
- return [.selectLocation, .connect]
-
- case .connecting, .pendingReconnect, .disconnecting(.reconnect),
- .waitingForConnectivity:
- return [.selectLocation, .cancel]
-
- case .connected, .reconnecting:
- return [.selectLocation, .disconnect]
- }
-
- case (.pad, .regular):
- switch self {
- case .disconnected, .disconnecting(.nothing):
- return [.connect]
-
- case .connecting, .pendingReconnect, .disconnecting(.reconnect),
- .waitingForConnectivity:
- return [.cancel]
-
- case .connected, .reconnecting:
- return [.disconnect]
- }
-
- default:
- return []
- }
- }
-}
-
-private extension MKCoordinateRegion {
- func isApproximatelyEqualTo(_ other: MKCoordinateRegion) -> Bool {
- return fabs(center.latitude - other.center.latitude) <= .ulpOfOne &&
- fabs(center.longitude - other.center.longitude) <= .ulpOfOne &&
- fabs(span.latitudeDelta - other.span.latitudeDelta) <= .ulpOfOne &&
- fabs(span.longitudeDelta - other.span.longitudeDelta) <= .ulpOfOne
- }
-}
diff --git a/ios/MullvadVPN/MapViewController.swift b/ios/MullvadVPN/MapViewController.swift
new file mode 100644
index 0000000000..ea6988196f
--- /dev/null
+++ b/ios/MullvadVPN/MapViewController.swift
@@ -0,0 +1,273 @@
+//
+// MapViewController.swift
+// MullvadVPN
+//
+// Created by pronebird on 03/01/2023.
+// Copyright © 2023 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+import MapKit
+import MullvadLogging
+import Operations
+
+private let locationMarkerReuseIdentifier = "location"
+private let geoJSONSourceFileName = "countries.geo.json"
+
+final class MapViewController: UIViewController, MKMapViewDelegate {
+ private let logger = Logger(label: "MapViewController")
+ private let animationQueue: AsyncOperationQueue = {
+ let animationQueue = AsyncOperationQueue()
+ animationQueue.maxConcurrentOperationCount = 1
+ return animationQueue
+ }()
+
+ private let locationMarker = MKPointAnnotation()
+ private var willChangeRegion = false
+ private var regionDidChangeCompletion: (() -> Void)?
+ private let mapView = MKMapView()
+
+ private var center: CLLocationCoordinate2D?
+ var alignmentView: UIView?
+
+ // MARK: - View lifecycle
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+
+ mapView.delegate = self
+ mapView.register(
+ MKAnnotationView.self,
+ forAnnotationViewWithReuseIdentifier: locationMarkerReuseIdentifier
+ )
+
+ mapView.showsUserLocation = false
+ mapView.isZoomEnabled = false
+ mapView.isScrollEnabled = false
+ mapView.isUserInteractionEnabled = false
+ mapView.accessibilityElementsHidden = true
+
+ // Use dark style for the map to dim the map grid
+ mapView.overrideUserInterfaceStyle = .dark
+
+ addTileOverlay()
+ loadGeoJSONData()
+ addMapView()
+ }
+
+ override func viewWillTransition(
+ to size: CGSize,
+ with coordinator: UIViewControllerTransitionCoordinator
+ ) {
+ super.viewWillTransition(to: size, with: coordinator)
+
+ coordinator.animate(alongsideTransition: nil, completion: { context in
+ self.recomputeVisibleRegion(animated: context.isAnimated)
+ })
+ }
+
+ // MARK: - Public
+
+ func addLocationMarker(coordinate: CLLocationCoordinate2D) {
+ locationMarker.coordinate = coordinate
+ mapView.addAnnotation(locationMarker)
+ }
+
+ func removeLocationMarker() {
+ mapView.removeAnnotation(locationMarker)
+ }
+
+ func setCenter(
+ _ center: CLLocationCoordinate2D?,
+ animated: Bool,
+ completion: (() -> Void)? = nil
+ ) {
+ enqueueAnimation(cancelOtherAnimations: true) { finish in
+ self.setCenterInternal(center, animated: animated) {
+ finish()
+ completion?()
+ }
+ }
+ }
+
+ // MARK: - MKMapViewDelegate
+
+ func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
+ if let polygon = overlay as? MKPolygon {
+ let renderer = MKPolygonRenderer(polygon: polygon)
+ renderer.fillColor = .primaryColor
+ renderer.strokeColor = .secondaryColor
+ renderer.lineWidth = 1
+ renderer.lineCap = .round
+ renderer.lineJoin = .round
+ return renderer
+ }
+
+ if let tileOverlay = overlay as? MKTileOverlay {
+ return CustomOverlayRenderer(overlay: tileOverlay)
+ }
+
+ return MKOverlayRenderer()
+ }
+
+ func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
+ guard annotation === locationMarker else { return nil }
+
+ let view = mapView.dequeueReusableAnnotationView(
+ withIdentifier: locationMarkerReuseIdentifier,
+ for: annotation
+ )
+ view.isDraggable = false
+ view.canShowCallout = false
+ view.image = UIImage(named: "LocationMarkerSecure")
+
+ return view
+ }
+
+ func mapView(_ mapView: MKMapView, regionWillChangeAnimated animated: Bool) {
+ willChangeRegion = true
+ }
+
+ func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) {
+ willChangeRegion = false
+
+ let handler = regionDidChangeCompletion
+ regionDidChangeCompletion = nil
+ handler?()
+ }
+
+ // MARK: - Private
+
+ private func addMapView() {
+ mapView.translatesAutoresizingMaskIntoConstraints = false
+ view.addSubview(mapView)
+
+ NSLayoutConstraint.activate([
+ mapView.topAnchor.constraint(equalTo: view.topAnchor),
+ mapView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
+ mapView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
+ mapView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
+ ])
+ }
+
+ private func addTileOverlay() {
+ let tileOverlay = MKTileOverlay(urlTemplate: nil)
+ tileOverlay.canReplaceMapContent = true
+
+ mapView.addOverlay(tileOverlay, level: .aboveLabels)
+ }
+
+ private func loadGeoJSONData() {
+ guard let fileURL = Bundle.main.url(forResource: geoJSONSourceFileName, withExtension: nil)
+ else {
+ logger.debug("Failed to locate \(geoJSONSourceFileName) in main bundle.")
+ return
+ }
+
+ do {
+ let data = try Data(contentsOf: fileURL)
+ let overlays = try GeoJSON.decodeGeoJSON(data)
+
+ mapView.addOverlays(overlays, level: .aboveLabels)
+ } catch {
+ logger.error(error: error, message: "Failed to load geojson.")
+ }
+ }
+
+ private func setCenterInternal(
+ _ center: CLLocationCoordinate2D?,
+ animated: Bool,
+ completion: (() -> Void)?
+ ) {
+ let region = makeRegion(center: center)
+
+ self.center = center
+
+ // Map view does not call delegate methods when attempting to set the same region.
+ mapView.setRegion(region, animated: animated)
+
+ if willChangeRegion {
+ regionDidChangeCompletion = completion
+ } else {
+ completion?()
+ }
+ }
+
+ private func recomputeVisibleRegion(animated: Bool) {
+ enqueueAnimation(cancelOtherAnimations: false) { finish in
+ self.setCenterInternal(self.center, animated: animated, completion: finish)
+ }
+ }
+
+ private func enqueueAnimation(
+ cancelOtherAnimations: Bool,
+ block: @escaping (_ finish: @escaping () -> Void) -> Void
+ ) {
+ let operation = AsyncBlockOperation(dispatchQueue: .main) { operation in
+ block {
+ operation.finish()
+ }
+ }
+
+ if cancelOtherAnimations {
+ animationQueue.cancelAllOperations()
+ }
+
+ animationQueue.addOperation(operation)
+ }
+
+ private func makeRegion(center: CLLocationCoordinate2D?) -> MKCoordinateRegion {
+ guard let center = center else {
+ return makeZoomedOutRegion()
+ }
+
+ let sourceRegion = makeZoomedInRegion(center: center)
+
+ guard let alignmentView = alignmentView else {
+ return sourceRegion
+ }
+
+ return makeRegion(from: sourceRegion, withCenterMatching: alignmentView)
+ }
+
+ private func makeZoomedInRegion(center: CLLocationCoordinate2D) -> MKCoordinateRegion {
+ let span = MKCoordinateSpan(latitudeDelta: 30, longitudeDelta: 30)
+ let region = MKCoordinateRegion(center: center, span: span)
+
+ return mapView.regionThatFits(region)
+ }
+
+ private func makeZoomedOutRegion() -> MKCoordinateRegion {
+ let coordinate = CLLocationCoordinate2D(latitude: 0, longitude: 0)
+ let span = MKCoordinateSpan(latitudeDelta: 90, longitudeDelta: 90)
+ let region = MKCoordinateRegion(center: coordinate, span: span)
+
+ return mapView.regionThatFits(region)
+ }
+
+ private func makeRegion(
+ from region: MKCoordinateRegion,
+ withCenterMatching alignmentView: UIView
+ ) -> MKCoordinateRegion {
+ // Map view center lies within layout margins frame.
+ let mapViewLayoutFrame = mapView.layoutMarginsGuide.layoutFrame
+
+ // MKMapView.convert(_:toRectTo:) returns CGRect scaled to the zoom level derived from
+ // currently set region.
+ // Calculate the ratio that we can use to translate the rect within its own coordinate
+ // system before converting it into MKCoordinateRegion.
+ let newZoomLevel = mapViewLayoutFrame.width / region.span.longitudeDelta
+ let currentZoomLevel = mapViewLayoutFrame.width / mapView.region.span.longitudeDelta
+ let zoomDelta = currentZoomLevel / newZoomLevel
+
+ let alignmentViewRect = alignmentView.convert(alignmentView.bounds, to: mapView)
+ let horizontalOffset = (mapViewLayoutFrame.midX - alignmentViewRect.midX) * zoomDelta
+ let verticalOffset = (mapViewLayoutFrame.midY - alignmentViewRect.midY) * zoomDelta
+
+ let regionRect = mapView.convert(region, toRectTo: mapView)
+ let offsetRegionRect = regionRect.offsetBy(dx: horizontalOffset, dy: verticalOffset)
+ let offsetRegion = mapView.convert(offsetRegionRect, toRegionFrom: mapView)
+
+ return offsetRegion
+ }
+}
diff --git a/ios/MullvadVPN/SceneDelegate.swift b/ios/MullvadVPN/SceneDelegate.swift
index 1f3838b2ae..a08ebcca75 100644
--- a/ios/MullvadVPN/SceneDelegate.swift
+++ b/ios/MullvadVPN/SceneDelegate.swift
@@ -16,10 +16,10 @@ import UIKit
class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewControllerDelegate,
UIAdaptivePresentationControllerDelegate, RootContainerViewControllerDelegate,
LoginViewControllerDelegate, DeviceManagementViewControllerDelegate,
- SettingsNavigationControllerDelegate, ConnectViewControllerDelegate,
- OutOfTimeViewControllerDelegate, SelectLocationViewControllerDelegate,
- RevokedDeviceViewControllerDelegate, NotificationManagerDelegate, TunnelObserver,
- RelayCacheTrackerObserver, SettingsMigrationUIHandler
+ SettingsNavigationControllerDelegate, OutOfTimeViewControllerDelegate,
+ SelectLocationViewControllerDelegate, RevokedDeviceViewControllerDelegate,
+ NotificationManagerDelegate, TunnelObserver, RelayCacheTrackerObserver,
+ SettingsMigrationUIHandler
{
private let logger = Logger(label: "SceneDelegate")
@@ -35,7 +35,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewControllerDe
private var splitViewController: CustomSplitViewController?
private var selectLocationViewController: SelectLocationViewController?
- private var connectController: ConnectViewController?
+ private var tunnelViewController: TunnelViewController?
private weak var settingsNavController: SettingsNavigationController?
private var lastLoginAction: LoginAction?
@@ -140,6 +140,24 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewControllerDe
deviceDataThrottling?.reset()
}
+ private func showSelectLocationController() {
+ let contentController = makeSelectLocationController()
+ contentController.navigationItem.rightBarButtonItem = UIBarButtonItem(
+ barButtonSystemItem: .done,
+ target: self,
+ action: #selector(handleDismissSelectLocationController(_:))
+ )
+
+ let navController = SelectLocationNavigationController(contentController: contentController)
+ rootContainer.present(navController, animated: true)
+
+ selectLocationViewController = contentController
+ }
+
+ @objc private func handleDismissSelectLocationController(_ sender: Any) {
+ selectLocationViewController?.dismiss(animated: true)
+ }
+
// MARK: - UIWindowSceneDelegate
func scene(
@@ -255,7 +273,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewControllerDe
private func setupPadUI() {
let selectLocationController = makeSelectLocationController()
- let connectController = makeConnectViewController()
+ let tunnelController = makeTunnelViewController()
let splitViewController = CustomSplitViewController()
splitViewController.delegate = self
@@ -264,11 +282,11 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewControllerDe
.maximumSplitViewSidebarWidthFraction
splitViewController.primaryEdge = .trailing
splitViewController.dividerColor = UIColor.MainSplitView.dividerColor
- splitViewController.viewControllers = [selectLocationController, connectController]
+ splitViewController.viewControllers = [selectLocationController, tunnelController]
selectLocationViewController = selectLocationController
self.splitViewController = splitViewController
- self.connectController = connectController
+ tunnelViewController = tunnelController
rootContainer.setViewControllers([splitViewController], animated: false)
showSplitViewMaster(tunnelManager.deviceState.isLoggedIn, animated: false)
@@ -354,9 +372,9 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewControllerDe
switch self.tunnelManager.deviceState {
case .loggedIn:
- let connectController = self.makeConnectViewController()
- self.connectController = connectController
- viewControllers.append(connectController)
+ let tunnelViewController = self.makeTunnelViewController()
+ self.tunnelViewController = tunnelViewController
+ viewControllers.append(tunnelViewController)
case .loggedOut:
break
@@ -417,13 +435,13 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewControllerDe
return viewController
}
- private func makeConnectViewController() -> ConnectViewController {
- let connectController = ConnectViewController(
- interactor: ConnectInteractor(tunnelManager: tunnelManager)
- )
- connectController.delegate = self
-
- return connectController
+ private func makeTunnelViewController() -> TunnelViewController {
+ let interactor = TunnelViewControllerInteractor(tunnelManager: tunnelManager)
+ let tunnelViewController = TunnelViewController(interactor: interactor)
+ tunnelViewController.shouldShowSelectLocationPicker = { [weak self] in
+ self?.showSelectLocationController()
+ }
+ return tunnelViewController
}
private func makeSelectLocationController() -> SelectLocationViewController {
@@ -503,7 +521,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewControllerDe
private func showSplitViewMaster(_ show: Bool, animated: Bool) {
splitViewController?.preferredDisplayMode = show ? .allVisible : .primaryHidden
- connectController?.setMainContentHidden(!show, animated: animated)
+ tunnelViewController?.setMainContentHidden(!show, animated: animated)
}
private func showLoginViewAfterLogout(dismissController: UIViewController?) {
@@ -672,10 +690,10 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewControllerDe
switch UIDevice.current.userInterfaceIdiom {
case .phone:
- let connectController = makeConnectViewController()
- self.connectController = connectController
+ let tunnelViewController = makeTunnelViewController()
+ self.tunnelViewController = tunnelViewController
var viewControllers = rootContainer.viewControllers
- viewControllers.append(connectController)
+ viewControllers.append(tunnelViewController)
rootContainer.setViewControllers(viewControllers, animated: true)
handleExpiredAccount()
case .pad:
@@ -768,33 +786,13 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewControllerDe
}
}
- // MARK: - ConnectViewControllerDelegate
-
- func connectViewControllerShouldShowSelectLocationPicker(_ controller: ConnectViewController) {
- let contentController = makeSelectLocationController()
- contentController.navigationItem.rightBarButtonItem = UIBarButtonItem(
- barButtonSystemItem: .done,
- target: self,
- action: #selector(handleDismissSelectLocationController(_:))
- )
-
- let navController = SelectLocationNavigationController(contentController: contentController)
- rootContainer.present(navController, animated: true)
-
- selectLocationViewController = contentController
- }
-
- @objc private func handleDismissSelectLocationController(_ sender: Any) {
- selectLocationViewController?.dismiss(animated: true)
- }
-
// MARK: - NotificationManagerDelegate
func notificationManagerDidUpdateInAppNotifications(
_ manager: NotificationManager,
notifications: [InAppNotificationDescriptor]
) {
- connectController?.notificationController.setNotifications(notifications, animated: true)
+ tunnelViewController?.notificationController.setNotifications(notifications, animated: true)
}
// MARK: - SelectLocationViewControllerDelegate
@@ -953,7 +951,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewControllerDe
-> UIViewController?
{
// Set the connect controller as primary when collapsing the split view
- return connectController
+ return tunnelViewController
}
func splitViewController(
diff --git a/ios/MullvadVPN/SelectLocationViewController.swift b/ios/MullvadVPN/SelectLocationViewController.swift
index d4ebeacaf8..17ea45c6fc 100644
--- a/ios/MullvadVPN/SelectLocationViewController.swift
+++ b/ios/MullvadVPN/SelectLocationViewController.swift
@@ -181,10 +181,10 @@ class SelectLocationViewController: UIViewController, UITableViewDelegate {
) {
super.viewWillTransition(to: size, with: coordinator)
- coordinator.animate { context in
- if let indexPath = self.dataSource?.indexPathForSelectedRelay() {
- self.tableView?.scrollToRow(at: indexPath, at: .middle, animated: false)
- }
+ coordinator.animate(alongsideTransition: nil) { context in
+ guard let indexPath = self.dataSource?.indexPathForSelectedRelay() else { return }
+
+ self.tableView?.scrollToRow(at: indexPath, at: .middle, animated: false)
}
}
diff --git a/ios/MullvadVPN/TunnelControlView.swift b/ios/MullvadVPN/TunnelControlView.swift
new file mode 100644
index 0000000000..b761d0d124
--- /dev/null
+++ b/ios/MullvadVPN/TunnelControlView.swift
@@ -0,0 +1,615 @@
+//
+// TunnelControlView.swift
+// MullvadVPN
+//
+// Created by pronebird on 09/03/2021.
+// Copyright © 2021 Mullvad VPN AB. All rights reserved.
+//
+
+import MapKit
+import MullvadTypes
+import UIKit
+
+enum TunnelControlAction {
+ case connect
+ case disconnect
+ case cancel
+ case reconnect
+ case selectLocation
+}
+
+private enum TunnelControlActionButton {
+ case connect
+ case disconnect
+ case cancel
+ case selectLocation
+}
+
+final class TunnelControlView: UIView {
+ private let secureLabel = makeBoldTextLabel(ofSize: 20)
+ private let cityLabel = makeBoldTextLabel(ofSize: 34)
+ private let countryLabel = makeBoldTextLabel(ofSize: 34)
+
+ private let activityIndicator: SpinnerActivityIndicatorView = {
+ let activityIndicator = SpinnerActivityIndicatorView(style: .large)
+ activityIndicator.translatesAutoresizingMaskIntoConstraints = false
+ activityIndicator.tintColor = .white
+ activityIndicator.setContentHuggingPriority(.defaultHigh, for: .horizontal)
+ activityIndicator.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
+ return activityIndicator
+ }()
+
+ private let locationContainerView: UIView = {
+ let view = UIView()
+ view.translatesAutoresizingMaskIntoConstraints = false
+ view.isAccessibilityElement = true
+ view.accessibilityTraits = .summaryElement
+ return view
+ }()
+
+ private let connectionPanel: ConnectionPanelView = {
+ let view = ConnectionPanelView()
+ view.translatesAutoresizingMaskIntoConstraints = false
+ return view
+ }()
+
+ private let buttonsStackView: UIStackView = {
+ let stackView = UIStackView()
+ stackView.spacing = UIMetrics.interButtonSpacing
+ stackView.axis = .vertical
+ stackView.translatesAutoresizingMaskIntoConstraints = false
+ return stackView
+ }()
+
+ private let connectButton: AppButton = {
+ let button = AppButton(style: .success)
+ button.translatesAutoresizingMaskIntoConstraints = false
+ return button
+ }()
+
+ private let cancelButton: AppButton = {
+ let button = AppButton(style: .translucentDanger)
+ button.accessibilityIdentifier = "CancelButton"
+ button.translatesAutoresizingMaskIntoConstraints = false
+ return button
+ }()
+
+ private let selectLocationButton: AppButton = {
+ let button = AppButton(style: .translucentNeutral)
+ button.accessibilityIdentifier = "SelectLocationButton"
+ button.translatesAutoresizingMaskIntoConstraints = false
+ return button
+ }()
+
+ private let selectLocationBlurView: TranslucentButtonBlurView
+ private let cancelButtonBlurView: TranslucentButtonBlurView
+
+ private let splitDisconnectButton: DisconnectSplitButton = {
+ let button = DisconnectSplitButton()
+ button.primaryButton.accessibilityIdentifier = "DisconnectButton"
+ button.translatesAutoresizingMaskIntoConstraints = false
+ return button
+ }()
+
+ private let containerView: UIView = {
+ let view = UIView()
+ view.translatesAutoresizingMaskIntoConstraints = false
+ return view
+ }()
+
+ private var traitConstraints = [NSLayoutConstraint]()
+ private var tunnelState: TunnelState = .disconnected
+
+ var actionHandler: ((TunnelControlAction) -> Void)?
+
+ var mapCenterAlignmentView: UIView {
+ return activityIndicator
+ }
+
+ override init(frame: CGRect) {
+ selectLocationBlurView = TranslucentButtonBlurView(button: selectLocationButton)
+ cancelButtonBlurView = TranslucentButtonBlurView(button: cancelButton)
+
+ super.init(frame: frame)
+
+ backgroundColor = .clear
+ layoutMargins = UIMetrics.contentLayoutMargins
+ accessibilityContainerType = .semanticGroup
+
+ addSubviews()
+ addButtonHandlers()
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
+ super.traitCollectionDidChange(previousTraitCollection)
+
+ if traitCollection.userInterfaceIdiom != previousTraitCollection?.userInterfaceIdiom {
+ updateTraitConstraints()
+ }
+
+ if previousTraitCollection?.userInterfaceIdiom != traitCollection.userInterfaceIdiom ||
+ previousTraitCollection?.horizontalSizeClass != traitCollection.horizontalSizeClass
+ {
+ updateActionButtons()
+ }
+ }
+
+ func update(from tunnelState: TunnelState, animated: Bool) {
+ self.tunnelState = tunnelState
+
+ updateSecureLabel()
+ updateActionButtons()
+ updateTunnelRelay()
+ }
+
+ func setAnimatingActivity(_ isAnimating: Bool) {
+ if isAnimating {
+ activityIndicator.startAnimating()
+ } else {
+ activityIndicator.stopAnimating()
+ }
+ }
+
+ private func updateActionButtons() {
+ let actionButtons = tunnelState.actionButtons(traitCollection: traitCollection)
+ let views = actionButtons.map { self.view(forActionButton: $0) }
+
+ updateButtonTitles()
+ setArrangedButtons(views)
+ }
+
+ private func updateSecureLabel() {
+ secureLabel.text = tunnelState.localizedTitleForSecureLabel.uppercased()
+ secureLabel.textColor = tunnelState.textColorForSecureLabel
+ }
+
+ private func updateButtonTitles() {
+ connectButton.setTitle(
+ NSLocalizedString(
+ "CONNECT_BUTTON_TITLE",
+ tableName: "Main",
+ value: "Secure connection",
+ comment: ""
+ ), for: .normal
+ )
+ selectLocationButton.setTitle(
+ tunnelState.localizedTitleForSelectLocationButton,
+ for: .normal
+ )
+ cancelButton.setTitle(
+ NSLocalizedString(
+ "CANCEL_BUTTON_TITLE",
+ tableName: "Main",
+ value: "Cancel",
+ comment: ""
+ ), for: .normal
+ )
+ splitDisconnectButton.primaryButton.setTitle(
+ NSLocalizedString(
+ "DISCONNECT_BUTTON_TITLE",
+ tableName: "Main",
+ value: "Disconnect",
+ comment: ""
+ ), for: .normal
+ )
+ splitDisconnectButton.secondaryButton.accessibilityLabel = NSLocalizedString(
+ "RECONNECT_BUTTON_ACCESSIBILITY_LABEL",
+ tableName: "Main",
+ value: "Reconnect",
+ comment: ""
+ )
+ }
+
+ private func updateTunnelRelay() {
+ if let tunnelRelay = tunnelState.relay {
+ cityLabel.attributedText = attributedStringForLocation(
+ string: tunnelRelay.location.city
+ )
+ countryLabel.attributedText = attributedStringForLocation(
+ string: tunnelRelay.location.country
+ )
+
+ connectionPanel.dataSource = ConnectionPanelData(
+ inAddress: "\(tunnelRelay.ipv4Relay) UDP",
+ outAddress: nil
+ )
+ connectionPanel.isHidden = false
+ connectionPanel.connectedRelayName = tunnelRelay.hostname
+ } else {
+ countryLabel.attributedText = attributedStringForLocation(string: " ")
+ cityLabel.attributedText = attributedStringForLocation(string: " ")
+ connectionPanel.dataSource = nil
+ connectionPanel.isHidden = true
+ }
+
+ locationContainerView.accessibilityLabel = tunnelState.localizedAccessibilityLabel
+ }
+
+ // MARK: - Private
+
+ private func addSubviews() {
+ for subview in [secureLabel, cityLabel, countryLabel] {
+ locationContainerView.addSubview(subview)
+ }
+
+ for subview in [
+ activityIndicator,
+ locationContainerView,
+ connectionPanel,
+ buttonsStackView,
+ ] {
+ containerView.addSubview(subview)
+ }
+
+ addSubview(containerView)
+
+ NSLayoutConstraint.activate([
+ containerView.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor),
+ containerView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor),
+ containerView.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor),
+
+ locationContainerView.topAnchor
+ .constraint(greaterThanOrEqualTo: containerView.topAnchor),
+ locationContainerView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
+ locationContainerView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
+
+ activityIndicator.centerXAnchor.constraint(equalTo: containerView.centerXAnchor),
+ locationContainerView.topAnchor.constraint(
+ equalTo: activityIndicator.bottomAnchor,
+ constant: 22
+ ),
+
+ secureLabel.topAnchor.constraint(equalTo: locationContainerView.topAnchor),
+ secureLabel.leadingAnchor.constraint(equalTo: locationContainerView.leadingAnchor),
+ secureLabel.trailingAnchor.constraint(equalTo: locationContainerView.trailingAnchor),
+
+ cityLabel.topAnchor.constraint(equalTo: secureLabel.bottomAnchor, constant: 8),
+ cityLabel.leadingAnchor.constraint(equalTo: locationContainerView.leadingAnchor),
+ cityLabel.trailingAnchor.constraint(equalTo: locationContainerView.trailingAnchor),
+
+ countryLabel.topAnchor.constraint(equalTo: cityLabel.bottomAnchor, constant: 8),
+ countryLabel.leadingAnchor.constraint(equalTo: locationContainerView.leadingAnchor),
+ countryLabel.trailingAnchor.constraint(equalTo: locationContainerView.trailingAnchor),
+ countryLabel.bottomAnchor.constraint(equalTo: locationContainerView.bottomAnchor),
+
+ connectionPanel.topAnchor.constraint(
+ equalTo: locationContainerView.bottomAnchor,
+ constant: 8
+ ),
+ connectionPanel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
+ connectionPanel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
+
+ buttonsStackView.topAnchor.constraint(
+ equalTo: connectionPanel.bottomAnchor,
+ constant: 24
+ ),
+ buttonsStackView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
+ buttonsStackView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
+ buttonsStackView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
+ ])
+
+ updateTraitConstraints()
+ }
+
+ private func addButtonHandlers() {
+ connectButton.addTarget(
+ self,
+ action: #selector(handleConnect),
+ for: .touchUpInside
+ )
+ cancelButton.addTarget(
+ self,
+ action: #selector(handleDisconnect),
+ for: .touchUpInside
+ )
+ splitDisconnectButton.primaryButton.addTarget(
+ self,
+ action: #selector(handleDisconnect),
+ for: .touchUpInside
+ )
+ splitDisconnectButton.secondaryButton.addTarget(
+ self,
+ action: #selector(handleReconnect),
+ for: .touchUpInside
+ )
+ selectLocationButton.addTarget(
+ self,
+ action: #selector(handleSelectLocation),
+ for: .touchUpInside
+ )
+ }
+
+ private func updateTraitConstraints() {
+ var layoutConstraints = [NSLayoutConstraint]()
+
+ switch traitCollection.userInterfaceIdiom {
+ case .pad:
+ // Max container width is 70% width of iPad in portrait mode
+ let maxWidth = min(
+ UIScreen.main.nativeBounds.width * 0.7,
+ UIMetrics.maximumSplitViewContentContainerWidth
+ )
+
+ layoutConstraints.append(contentsOf: [
+ containerView.trailingAnchor.constraint(
+ lessThanOrEqualTo: layoutMarginsGuide.trailingAnchor
+ ),
+ containerView.widthAnchor.constraint(equalToConstant: maxWidth)
+ .withPriority(.defaultHigh),
+ ])
+
+ case .phone:
+ layoutConstraints.append(
+ containerView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor)
+ )
+
+ default:
+ break
+ }
+
+ removeConstraints(traitConstraints)
+ traitConstraints = layoutConstraints
+ NSLayoutConstraint.activate(layoutConstraints)
+ }
+
+ private func setArrangedButtons(_ newButtons: [UIView]) {
+ buttonsStackView.arrangedSubviews.forEach { button in
+ if !newButtons.contains(button) {
+ buttonsStackView.removeArrangedSubview(button)
+ button.removeFromSuperview()
+ }
+ }
+
+ newButtons.forEach { button in
+ buttonsStackView.addArrangedSubview(button)
+ }
+ }
+
+ private func view(forActionButton actionButton: TunnelControlActionButton) -> UIView {
+ switch actionButton {
+ case .connect:
+ return connectButton
+ case .disconnect:
+ return splitDisconnectButton
+ case .cancel:
+ return cancelButtonBlurView
+ case .selectLocation:
+ return selectLocationBlurView
+ }
+ }
+
+ private func attributedStringForLocation(string: String) -> NSAttributedString {
+ let paragraphStyle = NSMutableParagraphStyle()
+ paragraphStyle.lineSpacing = 0
+ paragraphStyle.lineHeightMultiple = 0.80
+
+ return NSAttributedString(
+ string: string,
+ attributes: [.paragraphStyle: paragraphStyle]
+ )
+ }
+
+ private class func makeBoldTextLabel(ofSize fontSize: CGFloat) -> UILabel {
+ let textLabel = UILabel()
+ textLabel.translatesAutoresizingMaskIntoConstraints = false
+ textLabel.font = UIFont.boldSystemFont(ofSize: fontSize)
+ textLabel.textColor = .white
+ return textLabel
+ }
+
+ // MARK: - Actions
+
+ @objc private func handleConnect() {
+ actionHandler?(.connect)
+ }
+
+ @objc private func handleDisconnect() {
+ actionHandler?(.disconnect)
+ }
+
+ @objc private func handleReconnect() {
+ actionHandler?(.reconnect)
+ }
+
+ @objc private func handleSelectLocation() {
+ actionHandler?(.selectLocation)
+ }
+}
+
+private extension TunnelState {
+ var textColorForSecureLabel: UIColor {
+ switch self {
+ case .connecting, .reconnecting, .waitingForConnectivity:
+ return .white
+
+ case .connected:
+ return .successColor
+
+ case .disconnecting, .disconnected, .pendingReconnect:
+ return .dangerColor
+ }
+ }
+
+ var localizedTitleForSecureLabel: String {
+ switch self {
+ case .connecting, .reconnecting:
+ return NSLocalizedString(
+ "TUNNEL_STATE_CONNECTING",
+ tableName: "Main",
+ value: "Creating secure connection",
+ comment: ""
+ )
+
+ case .connected:
+ return NSLocalizedString(
+ "TUNNEL_STATE_CONNECTED",
+ tableName: "Main",
+ value: "Secure connection",
+ comment: ""
+ )
+
+ case .disconnecting(.nothing):
+ return NSLocalizedString(
+ "TUNNEL_STATE_DISCONNECTING",
+ tableName: "Main",
+ value: "Disconnecting",
+ comment: ""
+ )
+ case .disconnecting(.reconnect), .pendingReconnect:
+ return NSLocalizedString(
+ "TUNNEL_STATE_PENDING_RECONNECT",
+ tableName: "Main",
+ value: "Reconnecting",
+ comment: ""
+ )
+
+ case .disconnected:
+ return NSLocalizedString(
+ "TUNNEL_STATE_DISCONNECTED",
+ tableName: "Main",
+ value: "Unsecured connection",
+ comment: ""
+ )
+
+ case .waitingForConnectivity:
+ return NSLocalizedString(
+ "TUNNEL_STATE_WAITING_FOR_CONNECTIVITY",
+ tableName: "Main",
+ value: "Blocked connection",
+ comment: ""
+ )
+ }
+ }
+
+ var localizedTitleForSelectLocationButton: String? {
+ switch self {
+ case .disconnecting(.reconnect), .pendingReconnect:
+ return NSLocalizedString(
+ "SWITCH_LOCATION_BUTTON_TITLE",
+ tableName: "Main",
+ value: "Select location",
+ comment: ""
+ )
+
+ case .disconnected, .disconnecting(.nothing):
+ return NSLocalizedString(
+ "SELECT_LOCATION_BUTTON_TITLE",
+ tableName: "Main",
+ value: "Select location",
+ comment: ""
+ )
+ case .connecting, .connected, .reconnecting, .waitingForConnectivity:
+ return NSLocalizedString(
+ "SWITCH_LOCATION_BUTTON_TITLE",
+ tableName: "Main",
+ value: "Switch location",
+ comment: ""
+ )
+ }
+ }
+
+ var localizedAccessibilityLabel: String {
+ switch self {
+ case .connecting:
+ return NSLocalizedString(
+ "TUNNEL_STATE_CONNECTING_ACCESSIBILITY_LABEL",
+ tableName: "Main",
+ value: "Creating secure connection",
+ comment: ""
+ )
+
+ case let .connected(tunnelInfo):
+ return String(
+ format: NSLocalizedString(
+ "TUNNEL_STATE_CONNECTED_ACCESSIBILITY_LABEL",
+ tableName: "Main",
+ value: "Secure connection. Connected to %@, %@",
+ comment: ""
+ ),
+ tunnelInfo.location.city,
+ tunnelInfo.location.country
+ )
+
+ case .disconnected:
+ return NSLocalizedString(
+ "TUNNEL_STATE_DISCONNECTED_ACCESSIBILITY_LABEL",
+ tableName: "Main",
+ value: "Unsecured connection",
+ comment: ""
+ )
+
+ case let .reconnecting(tunnelInfo):
+ return String(
+ format: NSLocalizedString(
+ "TUNNEL_STATE_RECONNECTING_ACCESSIBILITY_LABEL",
+ tableName: "Main",
+ value: "Reconnecting to %@, %@",
+ comment: ""
+ ),
+ tunnelInfo.location.city,
+ tunnelInfo.location.country
+ )
+
+ case .waitingForConnectivity:
+ return NSLocalizedString(
+ "TUNNEL_STATE_WAITING_FOR_CONNECTIVITY_ACCESSIBILITY_LABEL",
+ tableName: "Main",
+ value: "Blocked connection",
+ comment: ""
+ )
+
+ case .disconnecting(.nothing):
+ return NSLocalizedString(
+ "TUNNEL_STATE_DISCONNECTING_ACCESSIBILITY_LABEL",
+ tableName: "Main",
+ value: "Disconnecting",
+ comment: ""
+ )
+
+ case .disconnecting(.reconnect), .pendingReconnect:
+ return NSLocalizedString(
+ "TUNNEL_STATE_PENDING_RECONNECT_ACCESSIBILITY_LABEL",
+ tableName: "Main",
+ value: "Reconnecting",
+ comment: ""
+ )
+ }
+ }
+
+ func actionButtons(traitCollection: UITraitCollection) -> [TunnelControlActionButton] {
+ switch (traitCollection.userInterfaceIdiom, traitCollection.horizontalSizeClass) {
+ case (.phone, _), (.pad, .compact):
+ switch self {
+ case .disconnected, .disconnecting(.nothing):
+ return [.selectLocation, .connect]
+
+ case .connecting, .pendingReconnect, .disconnecting(.reconnect),
+ .waitingForConnectivity:
+ return [.selectLocation, .cancel]
+
+ case .connected, .reconnecting:
+ return [.selectLocation, .disconnect]
+ }
+
+ case (.pad, .regular):
+ switch self {
+ case .disconnected, .disconnecting(.nothing):
+ return [.connect]
+
+ case .connecting, .pendingReconnect, .disconnecting(.reconnect),
+ .waitingForConnectivity:
+ return [.cancel]
+
+ case .connected, .reconnecting:
+ return [.disconnect]
+ }
+
+ default:
+ return []
+ }
+ }
+}
diff --git a/ios/MullvadVPN/TunnelManager/TunnelState.swift b/ios/MullvadVPN/TunnelManager/TunnelState.swift
index 176f58e514..d7ba65f673 100644
--- a/ios/MullvadVPN/TunnelManager/TunnelState.swift
+++ b/ios/MullvadVPN/TunnelManager/TunnelState.swift
@@ -89,6 +89,17 @@ enum TunnelState: Equatable, CustomStringConvertible {
return false
}
}
+
+ var relay: PacketTunnelRelay? {
+ switch self {
+ case let .connected(relay), let .reconnecting(relay):
+ return relay
+ case let .connecting(relay):
+ return relay
+ case .disconnecting, .disconnected, .waitingForConnectivity, .pendingReconnect:
+ return nil
+ }
+ }
}
/// A enum that describes the action to perform after disconnect.
diff --git a/ios/MullvadVPN/TunnelViewController.swift b/ios/MullvadVPN/TunnelViewController.swift
new file mode 100644
index 0000000000..4f190995b6
--- /dev/null
+++ b/ios/MullvadVPN/TunnelViewController.swift
@@ -0,0 +1,210 @@
+//
+// TunnelViewController.swift
+// MullvadVPN
+//
+// Created by pronebird on 20/03/2019.
+// Copyright © 2019 Mullvad VPN AB. All rights reserved.
+//
+
+import MapKit
+import MullvadLogging
+import MullvadTypes
+import TunnelProviderMessaging
+import UIKit
+
+class TunnelViewController: UIViewController, RootContainment {
+ private let logger = Logger(label: "TunnelViewController")
+ private let interactor: TunnelViewControllerInteractor
+ private let contentView = TunnelControlView(frame: CGRect(x: 0, y: 0, width: 320, height: 480))
+ private var tunnelState: TunnelState = .disconnected
+
+ var shouldShowSelectLocationPicker: (() -> Void)?
+
+ let notificationController = NotificationController()
+ private let mapViewController = MapViewController()
+
+ override var preferredStatusBarStyle: UIStatusBarStyle {
+ return .lightContent
+ }
+
+ var preferredHeaderBarPresentation: HeaderBarPresentation {
+ switch interactor.deviceState {
+ case .loggedIn, .revoked:
+ return HeaderBarPresentation(
+ style: tunnelState.isSecured ? .secured : .unsecured,
+ showsDivider: false
+ )
+ case .loggedOut:
+ return HeaderBarPresentation(style: .default, showsDivider: true)
+ }
+ }
+
+ var prefersHeaderBarHidden: Bool {
+ return false
+ }
+
+ init(interactor: TunnelViewControllerInteractor) {
+ self.interactor = interactor
+
+ super.init(nibName: nil, bundle: nil)
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+
+ interactor.didUpdateDeviceState = { [weak self] deviceState in
+ self?.setNeedsHeaderBarStyleAppearanceUpdate()
+ }
+
+ interactor.didUpdateTunnelStatus = { [weak self] tunnelStatus in
+ self?.setTunnelState(tunnelStatus.state, animated: true)
+ }
+
+ contentView.actionHandler = { [weak self] action in
+ switch action {
+ case .connect:
+ self?.interactor.startTunnel()
+
+ case .disconnect, .cancel:
+ self?.interactor.stopTunnel()
+
+ case .reconnect:
+ self?.interactor.reconnectTunnel(selectNewRelay: true)
+
+ case .selectLocation:
+ self?.shouldShowSelectLocationPicker?()
+ }
+ }
+
+ addMapController()
+ addContentView()
+ addNotificationController()
+
+ tunnelState = interactor.tunnelStatus.state
+ updateContentView(animated: false)
+ updateMap(animated: false)
+ }
+
+ override func viewWillTransition(
+ to size: CGSize,
+ with coordinator: UIViewControllerTransitionCoordinator
+ ) {
+ super.viewWillTransition(to: size, with: coordinator)
+
+ coordinator.animate(alongsideTransition: nil, completion: { context in
+ self.updateContentView(animated: context.isAnimated)
+ })
+ }
+
+ func setMainContentHidden(_ isHidden: Bool, animated: Bool) {
+ let actions = {
+ self.contentView.alpha = isHidden ? 0 : 1
+ }
+
+ if animated {
+ UIView.animate(withDuration: 0.25, animations: actions)
+ } else {
+ actions()
+ }
+ }
+
+ // MARK: - Private
+
+ private func setTunnelState(_ tunnelState: TunnelState, animated: Bool) {
+ self.tunnelState = tunnelState
+ setNeedsHeaderBarStyleAppearanceUpdate()
+
+ guard isViewLoaded else { return }
+
+ updateContentView(animated: animated)
+ updateMap(animated: animated)
+ }
+
+ private func updateMap(animated: Bool) {
+ switch tunnelState {
+ case let .connecting(tunnelRelay):
+ mapViewController.removeLocationMarker()
+ contentView.setAnimatingActivity(true)
+ mapViewController.setCenter(tunnelRelay?.location.geoCoordinate, animated: animated)
+
+ case let .reconnecting(tunnelRelay):
+ mapViewController.removeLocationMarker()
+ contentView.setAnimatingActivity(true)
+ mapViewController.setCenter(tunnelRelay.location.geoCoordinate, animated: animated)
+
+ case let .connected(tunnelRelay):
+ let center = tunnelRelay.location.geoCoordinate
+
+ mapViewController.setCenter(center, animated: animated) {
+ self.contentView.setAnimatingActivity(false)
+ self.mapViewController.addLocationMarker(coordinate: center)
+ }
+
+ case .pendingReconnect:
+ mapViewController.removeLocationMarker()
+ contentView.setAnimatingActivity(true)
+
+ case .waitingForConnectivity:
+ mapViewController.removeLocationMarker()
+ contentView.setAnimatingActivity(false)
+
+ case .disconnected, .disconnecting:
+ mapViewController.removeLocationMarker()
+ contentView.setAnimatingActivity(false)
+ mapViewController.setCenter(nil, animated: animated)
+ }
+ }
+
+ private func updateContentView(animated: Bool) {
+ contentView.update(from: tunnelState, animated: animated)
+ }
+
+ private func addMapController() {
+ let mapView = mapViewController.view!
+ mapView.translatesAutoresizingMaskIntoConstraints = false
+ mapViewController.alignmentView = contentView.mapCenterAlignmentView
+
+ addChild(mapViewController)
+ view.addSubview(mapView)
+ mapViewController.didMove(toParent: self)
+
+ NSLayoutConstraint.activate([
+ mapView.topAnchor.constraint(equalTo: view.topAnchor),
+ mapView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
+ mapView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
+ mapView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
+ ])
+ }
+
+ private func addNotificationController() {
+ let notificationView = notificationController.view!
+ notificationView.translatesAutoresizingMaskIntoConstraints = false
+
+ addChild(notificationController)
+ view.addSubview(notificationView)
+ notificationController.didMove(toParent: self)
+
+ NSLayoutConstraint.activate([
+ notificationView.topAnchor.constraint(equalTo: view.topAnchor),
+ notificationView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
+ notificationView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
+ notificationView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
+ ])
+ }
+
+ private func addContentView() {
+ contentView.translatesAutoresizingMaskIntoConstraints = false
+ view.addSubview(contentView)
+
+ NSLayoutConstraint.activate([
+ contentView.topAnchor.constraint(equalTo: view.topAnchor),
+ contentView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
+ contentView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
+ contentView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
+ ])
+ }
+}
diff --git a/ios/MullvadVPN/ConnectInteractor.swift b/ios/MullvadVPN/TunnelViewControllerInteractor.swift
index 579febf1aa..b23bc214f2 100644
--- a/ios/MullvadVPN/ConnectInteractor.swift
+++ b/ios/MullvadVPN/TunnelViewControllerInteractor.swift
@@ -1,5 +1,5 @@
//
-// ConnectInteractor.swift
+// TunnelViewControllerInteractor.swift
// MullvadVPN
//
// Created by pronebird on 26/10/2022.
@@ -8,7 +8,7 @@
import Foundation
-final class ConnectInteractor {
+final class TunnelViewControllerInteractor {
private let tunnelManager: TunnelManager
private var tunnelObserver: TunnelObserver?