diff options
| author | Andrej Mihajlov <and@mullvad.net> | 2023-01-10 11:32:48 +0100 |
|---|---|---|
| committer | Andrej Mihajlov <and@mullvad.net> | 2023-01-10 11:32:48 +0100 |
| commit | edc405fe3e1326069a5ba3526e82041ad2e7221a (patch) | |
| tree | f8562bed4dc5cb346009b0b7400b5fc1e447ff07 | |
| parent | 938177b737b07a5932d87ba67fe7377514619f06 (diff) | |
| parent | 9188e0f56bbb15a9a1c8d438d3d9439376be0a9c (diff) | |
| download | mullvadvpn-edc405fe3e1326069a5ba3526e82041ad2e7221a.tar.xz mullvadvpn-edc405fe3e1326069a5ba3526e82041ad2e7221a.zip | |
Merge branch 'align-map-marker'
| -rw-r--r-- | ios/BuildInstructions.md | 2 | ||||
| -rw-r--r-- | ios/MullvadVPN.xcodeproj/project.pbxproj | 28 | ||||
| -rw-r--r-- | ios/MullvadVPN/ConnectContentView.swift | 264 | ||||
| -rw-r--r-- | ios/MullvadVPN/ConnectViewController.swift | 728 | ||||
| -rw-r--r-- | ios/MullvadVPN/MapViewController.swift | 273 | ||||
| -rw-r--r-- | ios/MullvadVPN/SceneDelegate.swift | 86 | ||||
| -rw-r--r-- | ios/MullvadVPN/SelectLocationViewController.swift | 8 | ||||
| -rw-r--r-- | ios/MullvadVPN/TunnelControlView.swift | 615 | ||||
| -rw-r--r-- | ios/MullvadVPN/TunnelManager/TunnelState.swift | 11 | ||||
| -rw-r--r-- | ios/MullvadVPN/TunnelViewController.swift | 210 | ||||
| -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? |
