summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAndrej Mihajlov <and@mullvad.net>2022-08-02 13:58:40 +0200
committerAndrej Mihajlov <and@mullvad.net>2022-08-02 13:58:40 +0200
commit0523a5131466de2b2f8f10f46dbf1ab074c58c1c (patch)
tree478dd87cba6c6ee93daf0756f409c01a49978f87
parenta3df757018bc0b41036d2ec710819b16be356bb5 (diff)
parentaf87af81eaae462c35ede3722c381b26372778eb (diff)
downloadmullvadvpn-0523a5131466de2b2f8f10f46dbf1ab074c58c1c.tar.xz
mullvadvpn-0523a5131466de2b2f8f10f46dbf1ab074c58c1c.zip
Merge branch 'add-device-mgmt'
-rw-r--r--ios/CHANGELOG.md3
-rw-r--r--ios/MullvadVPN.xcodeproj/project.pbxproj34
-rw-r--r--ios/MullvadVPN/AppStorePaymentManager/SendAppStoreReceiptOperation.swift2
-rw-r--r--ios/MullvadVPN/ApplicationConfiguration.swift3
-rw-r--r--ios/MullvadVPN/Assets.xcassets/IconClose.imageset/Contents.json15
-rw-r--r--ios/MullvadVPN/Assets.xcassets/IconClose.imageset/IconClose.pdfbin0 -> 1128 bytes
-rw-r--r--ios/MullvadVPN/DataSourceSnapshot.swift83
-rw-r--r--ios/MullvadVPN/DeviceManagementContentView.swift224
-rw-r--r--ios/MullvadVPN/DeviceManagementInteractor.swift39
-rw-r--r--ios/MullvadVPN/DeviceManagementViewController.swift277
-rw-r--r--ios/MullvadVPN/DeviceRowView.swift111
-rw-r--r--ios/MullvadVPN/LoginViewController.swift101
-rw-r--r--ios/MullvadVPN/REST/RESTDevicesProxy.swift13
-rw-r--r--ios/MullvadVPN/REST/RESTError.swift4
-rw-r--r--ios/MullvadVPN/REST/SSLPinningURLSessionDelegate.swift6
-rw-r--r--ios/MullvadVPN/REST/ServerRelaysResponse.swift2
-rw-r--r--ios/MullvadVPN/RootContainerViewController.swift9
-rw-r--r--ios/MullvadVPN/SceneDelegate.swift101
-rw-r--r--ios/MullvadVPN/SelectLocationCell.swift3
-rw-r--r--ios/MullvadVPN/SelectLocationViewController.swift1
-rw-r--r--ios/MullvadVPN/TunnelManager/TunnelManager.swift24
-rw-r--r--ios/MullvadVPN/TunnelManager/TunnelManagerErrors.swift11
-rw-r--r--ios/MullvadVPN/TunnelManager/UpdateDeviceDataOperation.swift27
-rw-r--r--ios/MullvadVPN/UIMetrics.swift4
-rw-r--r--ios/MullvadVPN/WireguardKeysViewController.swift27
-rwxr-xr-xios/convert-assets.rb1
26 files changed, 966 insertions, 159 deletions
diff --git a/ios/CHANGELOG.md b/ios/CHANGELOG.md
index ac31d645b4..92ab5b5170 100644
--- a/ios/CHANGELOG.md
+++ b/ios/CHANGELOG.md
@@ -27,6 +27,9 @@ Line wrap the file at 100 chars. Th
- Add option to block gambling and adult content.
- Add last used account field to login view.
- Display device name under account view.
+- Add revoked device view displayed when the app detects that device is no longer registered on
+ backend.
+- Add ability to manage registered devices if too many devices detected during log-in.
### Fixed
- Improve random port distribution. Should be less biased towards port 53.
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj
index 55f041db82..8e807d6229 100644
--- a/ios/MullvadVPN.xcodeproj/project.pbxproj
+++ b/ios/MullvadVPN.xcodeproj/project.pbxproj
@@ -22,7 +22,6 @@
58095C572760F47900890776 /* api-ip-address.json in Resources */ = {isa = PBXBuildFile; fileRef = 58095C562760F47900890776 /* api-ip-address.json */; };
58095C592762155700890776 /* RESTRetryStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58095C582762155700890776 /* RESTRetryStrategy.swift */; };
580CBFB82848D503007878F0 /* OperationConditionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 580CBFB72848D503007878F0 /* OperationConditionTests.swift */; };
- 580D9C5E289298FD00B77A26 /* TunnelMonitorDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58E072A428814C28008902F8 /* TunnelMonitorDelegate.swift */; };
580EE22424B3243100F9D8A1 /* AsyncBlockOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 580EE22324B3243100F9D8A1 /* AsyncBlockOperation.swift */; };
580F8B8328197881002E0998 /* TunnelSettingsV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = 580F8B8228197881002E0998 /* TunnelSettingsV2.swift */; };
580F8B8428197884002E0998 /* TunnelSettingsV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = 580F8B8228197881002E0998 /* TunnelSettingsV2.swift */; };
@@ -55,6 +54,8 @@
5820676226E75D8500655B05 /* REST.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5820674D26E6510200655B05 /* REST.swift */; };
5820676426E771DB00655B05 /* TunnelManagerErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5820676326E771DB00655B05 /* TunnelManagerErrors.swift */; };
5820676826E79E7B00655B05 /* Result+UIBackgroundFetchResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5820676726E79E7B00655B05 /* Result+UIBackgroundFetchResult.swift */; };
+ 5820EDA9288FE064006BF4E4 /* DeviceManagementInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5820EDA8288FE064006BF4E4 /* DeviceManagementInteractor.swift */; };
+ 5820EDAB288FF0D2006BF4E4 /* DeviceRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5820EDAA288FF0D2006BF4E4 /* DeviceRowView.swift */; };
5823FA5426CE49F700283BF8 /* TunnelObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5823FA5326CE49F600283BF8 /* TunnelObserver.swift */; };
58289082286B590900478596 /* UIFont+Monospaced.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58289081286B590900478596 /* UIFont+Monospaced.swift */; };
58293FAE2510CA58005D0BB5 /* ProblemReportViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58293FAC2510CA58005D0BB5 /* ProblemReportViewController.swift */; };
@@ -172,6 +173,7 @@
587C575326D2615F005EF767 /* PacketTunnelOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587C575226D2615F005EF767 /* PacketTunnelOptions.swift */; };
587C575426D2615F005EF767 /* PacketTunnelOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587C575226D2615F005EF767 /* PacketTunnelOptions.swift */; };
587CBFE322807F530028DED3 /* UIColor+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587CBFE222807F530028DED3 /* UIColor+Helpers.swift */; };
+ 587D96742886D87C00CD8F1C /* DeviceManagementContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587D96732886D87C00CD8F1C /* DeviceManagementContentView.swift */; };
587D9676288989DB00CD8F1C /* NSLayoutConstraint+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587D9675288989DB00CD8F1C /* NSLayoutConstraint+Helpers.swift */; };
587DCCEF287D84A500CE821E /* countries.geo.json in Resources */ = {isa = PBXBuildFile; fileRef = 587DCCEE287D84A500CE821E /* countries.geo.json */; };
587EB66A270EFACB00123C75 /* CharacterSet+IPAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587EB669270EFACB00123C75 /* CharacterSet+IPAddress.swift */; };
@@ -193,6 +195,7 @@
5891BF1C25E3E3EB006D6FB0 /* Bundle+ProductVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5891BF1B25E3E3EB006D6FB0 /* Bundle+ProductVersion.swift */; };
5891BF5125E66B1E006D6FB0 /* UIBarButtonItem+KeyboardNavigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5891BF5025E66B1E006D6FB0 /* UIBarButtonItem+KeyboardNavigation.swift */; };
5892A45E265FABFF00890742 /* EmptyTableViewHeaderFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5892A45D265FABFF00890742 /* EmptyTableViewHeaderFooterView.swift */; };
+ 5893716A28817A45004EE76C /* DeviceManagementViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5893716928817A45004EE76C /* DeviceManagementViewController.swift */; };
58968FAE28743E2000B799DC /* TunnelInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58968FAD28743E2000B799DC /* TunnelInteractor.swift */; };
5896AE84246D5889005B36CB /* CustomDateComponentsFormatting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5896AE83246D5889005B36CB /* CustomDateComponentsFormatting.swift */; };
5896AE86246D6AD8005B36CB /* CustomDateComponentsFormattingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5896AE85246D6AD8005B36CB /* CustomDateComponentsFormattingTests.swift */; };
@@ -242,6 +245,8 @@
58CCA0162242560B004F3011 /* UIColor+Palette.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CCA0152242560B004F3011 /* UIColor+Palette.swift */; };
58CCA01822426713004F3011 /* AccountViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CCA01722426713004F3011 /* AccountViewController.swift */; };
58CCA01E2242787B004F3011 /* AccountTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CCA01D2242787B004F3011 /* AccountTextField.swift */; };
+ 58CE38C728992C8700A6D6E5 /* WireGuardAdapterError+Localization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58E07298288031D5008902F8 /* WireGuardAdapterError+Localization.swift */; };
+ 58CE38C828992C9200A6D6E5 /* TunnelMonitorDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58E072A428814C28008902F8 /* TunnelMonitorDelegate.swift */; };
58CE5E64224146200008646E /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CE5E63224146200008646E /* AppDelegate.swift */; };
58CE5E66224146200008646E /* LoginViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CE5E65224146200008646E /* LoginViewController.swift */; };
58CE5E6B224146210008646E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 58CE5E6A224146210008646E /* Assets.xcassets */; };
@@ -383,6 +388,8 @@
5820675D26E6839900655B05 /* PresentAlertOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresentAlertOperation.swift; sourceTree = "<group>"; };
5820676326E771DB00655B05 /* TunnelManagerErrors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelManagerErrors.swift; sourceTree = "<group>"; };
5820676726E79E7B00655B05 /* Result+UIBackgroundFetchResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Result+UIBackgroundFetchResult.swift"; sourceTree = "<group>"; };
+ 5820EDA8288FE064006BF4E4 /* DeviceManagementInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceManagementInteractor.swift; sourceTree = "<group>"; };
+ 5820EDAA288FF0D2006BF4E4 /* DeviceRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceRowView.swift; sourceTree = "<group>"; };
5823FA4F26CA690600283BF8 /* OSLogHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLogHandler.swift; sourceTree = "<group>"; };
5823FA5326CE49F600283BF8 /* TunnelObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelObserver.swift; sourceTree = "<group>"; };
58289081286B590900478596 /* UIFont+Monospaced.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFont+Monospaced.swift"; sourceTree = "<group>"; };
@@ -466,6 +473,7 @@
587B7544266922BF00DEF7E9 /* en */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
587C575226D2615F005EF767 /* PacketTunnelOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketTunnelOptions.swift; sourceTree = "<group>"; };
587CBFE222807F530028DED3 /* UIColor+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+Helpers.swift"; sourceTree = "<group>"; };
+ 587D96732886D87C00CD8F1C /* DeviceManagementContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceManagementContentView.swift; sourceTree = "<group>"; };
587D9675288989DB00CD8F1C /* NSLayoutConstraint+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSLayoutConstraint+Helpers.swift"; sourceTree = "<group>"; };
587DCCEE287D84A500CE821E /* countries.geo.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = countries.geo.json; sourceTree = "<group>"; };
587EB669270EFACB00123C75 /* CharacterSet+IPAddress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CharacterSet+IPAddress.swift"; sourceTree = "<group>"; };
@@ -484,6 +492,7 @@
5891BF1B25E3E3EB006D6FB0 /* Bundle+ProductVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+ProductVersion.swift"; sourceTree = "<group>"; };
5891BF5025E66B1E006D6FB0 /* UIBarButtonItem+KeyboardNavigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIBarButtonItem+KeyboardNavigation.swift"; sourceTree = "<group>"; };
5892A45D265FABFF00890742 /* EmptyTableViewHeaderFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyTableViewHeaderFooterView.swift; sourceTree = "<group>"; };
+ 5893716928817A45004EE76C /* DeviceManagementViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceManagementViewController.swift; sourceTree = "<group>"; };
58968FAD28743E2000B799DC /* TunnelInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelInteractor.swift; sourceTree = "<group>"; };
5896AE83246D5889005B36CB /* CustomDateComponentsFormatting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDateComponentsFormatting.swift; sourceTree = "<group>"; };
5896AE85246D6AD8005B36CB /* CustomDateComponentsFormattingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDateComponentsFormattingTests.swift; sourceTree = "<group>"; };
@@ -855,11 +864,9 @@
58F840B12464491D0044E708 /* ChainedError.swift */,
587EB669270EFACB00123C75 /* CharacterSet+IPAddress.swift */,
582AD43F27BE616E002A6BFC /* CodingErrors+ChainedError.swift */,
- 58A1AA8B23F5584B009F7EA6 /* ConnectionPanelView.swift */,
58B43C1825F77DB60002C8C3 /* ConnectContentView.swift */,
+ 58A1AA8B23F5584B009F7EA6 /* ConnectionPanelView.swift */,
58CCA00F224249A1004F3011 /* ConnectViewController.swift */,
- 584592602639B4A200EF967F /* TermsOfServiceContentView.swift */,
- 58A99ED2240014A0006599E9 /* TermsOfServiceViewController.swift */,
5871FB95254ADE4E0051A0A4 /* ConsolidatedApplicationLog.swift */,
5896AE83246D5889005B36CB /* CustomDateComponentsFormatting.swift */,
582BB1B0229569620055B6EF /* CustomNavigationBar.swift */,
@@ -870,6 +877,10 @@
58293FB025124117005D0BB5 /* CustomTextField.swift */,
58293FB2251241B3005D0BB5 /* CustomTextView.swift */,
587EB66F27143B6500123C75 /* DataSourceSnapshot.swift */,
+ 587D96732886D87C00CD8F1C /* DeviceManagementContentView.swift */,
+ 5820EDA8288FE064006BF4E4 /* DeviceManagementInteractor.swift */,
+ 5893716928817A45004EE76C /* DeviceManagementViewController.swift */,
+ 5820EDAA288FF0D2006BF4E4 /* DeviceRowView.swift */,
58907D9424D17B4E00CFC3F5 /* DisconnectSplitButton.swift */,
58B9EB142489139B00095626 /* DisplayChainedError.swift */,
580F8B8528197958002E0998 /* DNSSettings.swift */,
@@ -943,18 +954,20 @@
5807E2BF2432038B00F5FF30 /* String+Split.swift */,
E158B35F285381C60002F069 /* StringFormatter.swift */,
5871FB8225498CA20051A0A4 /* Swizzle.swift */,
+ 5872D6E7286304DE00DB5F4E /* TermsOfService.swift */,
+ 584592602639B4A200EF967F /* TermsOfServiceContentView.swift */,
+ 58A99ED2240014A0006599E9 /* TermsOfServiceViewController.swift */,
5862805322428EF100F5A6E1 /* TranslucentButtonBlurView.swift */,
5823FA5726CE4A4100283BF8 /* TunnelManager */,
5891BF5025E66B1E006D6FB0 /* UIBarButtonItem+KeyboardNavigation.swift */,
587CBFE222807F530028DED3 /* UIColor+Helpers.swift */,
58CCA0152242560B004F3011 /* UIColor+Palette.swift */,
+ 58289081286B590900478596 /* UIFont+Monospaced.swift */,
5856D13627450A8A00DFD627 /* UIImage+TintColor.swift */,
585CA70E25F8C44600B47C62 /* UIMetrics.swift */,
58FD5BF12424F7D700112C88 /* UserInterfaceInteractionRestriction.swift */,
58F7CA872692E34000FC59FD /* WireguardKeysContentView.swift */,
5877152F23981F7B001F8237 /* WireguardKeysViewController.swift */,
- 5872D6E7286304DE00DB5F4E /* TermsOfService.swift */,
- 58289081286B590900478596 /* UIFont+Monospaced.swift */,
);
path = MullvadVPN;
sourceTree = "<group>";
@@ -1292,6 +1305,7 @@
5896CEF226972DEB00B0FAE8 /* AccountContentView.swift in Sources */,
5840250122B1124600E4CFEC /* IPAddress+Codable.swift in Sources */,
5842102E282D3FC200F24E46 /* ResultBlockOperation.swift in Sources */,
+ 587D96742886D87C00CD8F1C /* DeviceManagementContentView.swift in Sources */,
5857F24724C882D700CF6F47 /* SelectLocationNavigationController.swift in Sources */,
5846227126E229F20035F7C2 /* AppStoreSubscription.swift in Sources */,
58421030282D8A3C00F24E46 /* UpdateAccountDataOperation.swift in Sources */,
@@ -1321,6 +1335,7 @@
58DF5B742851FF3F00E92647 /* InputOperation.swift in Sources */,
58F8AC0E25D3F8CE002BE0ED /* ProblemReportReviewViewController.swift in Sources */,
58CCA010224249A1004F3011 /* ConnectViewController.swift in Sources */,
+ 5893716A28817A45004EE76C /* DeviceManagementViewController.swift in Sources */,
58059DE02846823E002B1049 /* ResultOperation+Output.swift in Sources */,
588BCF282816D664009ADCEC /* RESTResponseHandler.swift in Sources */,
58554F77280AFD5C00013055 /* RESTTaskIdentifier.swift in Sources */,
@@ -1337,6 +1352,7 @@
584D26C2270C8542004EA533 /* SettingsStaticTextFooterView.swift in Sources */,
5877153023981F7B001F8237 /* WireguardKeysViewController.swift in Sources */,
587B7536266528A200DEF7E9 /* NotificationManager.swift in Sources */,
+ 5820EDA9288FE064006BF4E4 /* DeviceManagementInteractor.swift in Sources */,
58FB865A26EA214400F188BC /* RelayCacheObserver.swift in Sources */,
58ACF64D26567A5000ACE4B7 /* CustomSwitch.swift in Sources */,
58655DCE27DA0A5D00911834 /* TunnelMonitorConfiguration.swift in Sources */,
@@ -1355,6 +1371,7 @@
58293FB125124117005D0BB5 /* CustomTextField.swift in Sources */,
582AE3102440A6CA00E6733A /* AccountTokenInput.swift in Sources */,
58554F7D280D6FE000013055 /* RESTURLSession.swift in Sources */,
+ 5820EDAB288FF0D2006BF4E4 /* DeviceRowView.swift in Sources */,
589D28822846306C00F9A7B3 /* GroupOperation.swift in Sources */,
5846227726E22A7C0035F7C2 /* AppStorePaymentManagerDelegate.swift in Sources */,
5871FB8325498CA20051A0A4 /* Swizzle.swift in Sources */,
@@ -1487,7 +1504,6 @@
files = (
5850366825A47AC700A43E93 /* IPAddressRange+Codable.swift in Sources */,
58FB865F26EA2E6D00F188BC /* LogFormatting.swift in Sources */,
- 58E072A528814C28008902F8 /* TunnelMonitorDelegate.swift in Sources */,
587C575426D2615F005EF767 /* PacketTunnelOptions.swift in Sources */,
58BFA5CD22A7CE1F00A6173D /* ApplicationConfiguration.swift in Sources */,
5820675826E652AF00655B05 /* RelayCacheIO.swift in Sources */,
@@ -1496,8 +1512,8 @@
5806767C27048E9B00C858CB /* PacketTunnelProvider.swift in Sources */,
585DA89426B0323E00B8C587 /* TunnelProviderMessage.swift in Sources */,
587AD7C723421D8600E93A53 /* TunnelSettingsV1.swift in Sources */,
- 58E07299288031D5008902F8 /* WireGuardAdapterError+Localization.swift in Sources */,
58AEEF662344A37400C9BBD5 /* KeychainError.swift in Sources */,
+ 58CE38C828992C9200A6D6E5 /* TunnelMonitorDelegate.swift in Sources */,
582AD44127BE6178002A6BFC /* CodingErrors+ChainedError.swift in Sources */,
5840250222B1124600E4CFEC /* IPAddress+Codable.swift in Sources */,
58FC040A27B3EE03001C21F0 /* TunnelMonitor.swift in Sources */,
@@ -1522,12 +1538,12 @@
58F840B32464491D0044E708 /* ChainedError.swift in Sources */,
58D67A0A26D7AE3300557C3C /* OSLogHandler.swift in Sources */,
5820675626E6528A00655B05 /* RESTError.swift in Sources */,
- 580D9C5E289298FD00B77A26 /* TunnelMonitorDelegate.swift in Sources */,
58561C9A239A5D1500BD6B5E /* IPEndpoint.swift in Sources */,
58781CCE22AE8918009B9D8E /* RelayConstraints.swift in Sources */,
581503A024D6F01E00C9C50E /* LogRotation.swift in Sources */,
58781CD522AFBA39009B9D8E /* RelaySelector.swift in Sources */,
5877D70F282137E8002FCFC7 /* SettingsManager.swift in Sources */,
+ 58CE38C728992C8700A6D6E5 /* WireGuardAdapterError+Localization.swift in Sources */,
5820675926E652BE00655B05 /* RESTCoding.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
diff --git a/ios/MullvadVPN/AppStorePaymentManager/SendAppStoreReceiptOperation.swift b/ios/MullvadVPN/AppStorePaymentManager/SendAppStoreReceiptOperation.swift
index e9b2387f44..a95707b5c6 100644
--- a/ios/MullvadVPN/AppStorePaymentManager/SendAppStoreReceiptOperation.swift
+++ b/ios/MullvadVPN/AppStorePaymentManager/SendAppStoreReceiptOperation.swift
@@ -17,7 +17,7 @@ class SendAppStoreReceiptOperation: ResultOperation<REST.CreateApplePaymentRespo
private var fetchReceiptTask: Cancellable?
private var submitReceiptTask: Cancellable?
- private let logger = Logger(label: "AppStorePaymentManager.SendAppStoreReceiptOperation")
+ private let logger = Logger(label: "SendAppStoreReceiptOperation")
init(apiProxy: REST.APIProxy, accountToken: String, forceRefresh: Bool, receiptProperties: [String: Any]?, completionHandler: @escaping CompletionHandler) {
self.apiProxy = apiProxy
diff --git a/ios/MullvadVPN/ApplicationConfiguration.swift b/ios/MullvadVPN/ApplicationConfiguration.swift
index 5999c2ad22..f00413a296 100644
--- a/ios/MullvadVPN/ApplicationConfiguration.swift
+++ b/ios/MullvadVPN/ApplicationConfiguration.swift
@@ -59,6 +59,9 @@ class ApplicationConfiguration {
/// Default network timeout for API requests.
static let defaultAPINetworkTimeout: TimeInterval = 10
+ /// Maximum number of devices per account.
+ static let maxAllowedDevices = 5
+
/// Background fetch minimum interval
static let minimumBackgroundFetchInterval: TimeInterval = 3600
diff --git a/ios/MullvadVPN/Assets.xcassets/IconClose.imageset/Contents.json b/ios/MullvadVPN/Assets.xcassets/IconClose.imageset/Contents.json
new file mode 100644
index 0000000000..f5ff7c0c45
--- /dev/null
+++ b/ios/MullvadVPN/Assets.xcassets/IconClose.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images" : [
+ {
+ "filename" : "IconClose.pdf",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "preserves-vector-representation" : true
+ }
+}
diff --git a/ios/MullvadVPN/Assets.xcassets/IconClose.imageset/IconClose.pdf b/ios/MullvadVPN/Assets.xcassets/IconClose.imageset/IconClose.pdf
new file mode 100644
index 0000000000..cc53916273
--- /dev/null
+++ b/ios/MullvadVPN/Assets.xcassets/IconClose.imageset/IconClose.pdf
Binary files differ
diff --git a/ios/MullvadVPN/DataSourceSnapshot.swift b/ios/MullvadVPN/DataSourceSnapshot.swift
index cb97945b65..19b6043aa1 100644
--- a/ios/MullvadVPN/DataSourceSnapshot.swift
+++ b/ios/MullvadVPN/DataSourceSnapshot.swift
@@ -385,6 +385,12 @@ extension DataSourceSnapshot {
}
}
+struct StackViewApplyDataSnapshotConfiguration {
+ var animationDuration: TimeInterval = 0.25
+ var animationOptions: UIView.AnimationOptions = [.curveEaseInOut]
+ var makeView: (IndexPath) -> UIView
+}
+
struct DataSnapshotDifference: CustomDebugStringConvertible {
var indexPathsToInsert = [IndexPath]()
var indexPathsToDelete = [IndexPath]()
@@ -444,4 +450,81 @@ struct DataSnapshotDifference: CustomDebugStringConvertible {
}
}, completion: completion)
}
+
+ func apply(
+ to stackView: UIStackView,
+ configuration: StackViewApplyDataSnapshotConfiguration,
+ animateDifferences: Bool,
+ completion: ((Bool) -> Void)? = nil
+ )
+ {
+ let viewsToRemove = indexPathsToDelete.map { indexPath in
+ return stackView.arrangedSubviews[indexPath.row]
+ }
+
+ let viewsToAdd = indexPathsToInsert.map { indexPath -> UIView in
+ let view = configuration.makeView(indexPath)
+
+ view.isHidden = true
+ view.alpha = 0
+
+ var viewIndex = indexPath.row
+
+ // Adjust insertion index since views are not removed from stack view during animation.
+ for view in stackView.arrangedSubviews[..<indexPath.row] {
+ if viewsToRemove.contains(view) {
+ viewIndex += 1
+ }
+ }
+
+ stackView.insertArrangedSubview(view, at: viewIndex)
+
+ return view
+ }
+
+ // Layout inserted subviews before running animations to achieve a folding effect.
+ if animateDifferences {
+ UIView.performWithoutAnimation {
+ stackView.layoutIfNeeded()
+ }
+ }
+
+ let showHideViews = {
+ for view in viewsToRemove {
+ view.alpha = 0
+ view.isHidden = true
+ }
+
+ for view in viewsToAdd {
+ view.alpha = 1
+ view.isHidden = false
+ }
+ }
+
+ let removeViews = {
+ for view in viewsToRemove {
+ view.removeFromSuperview()
+ }
+ }
+
+ if animateDifferences {
+ UIView.animate(
+ withDuration: configuration.animationDuration,
+ delay: 0,
+ options: configuration.animationOptions,
+ animations: {
+ showHideViews()
+ stackView.layoutIfNeeded()
+ },
+ completion: { isComplete in
+ removeViews()
+ completion?(isComplete)
+ }
+ )
+ } else {
+ showHideViews()
+ removeViews()
+ completion?(true)
+ }
+ }
}
diff --git a/ios/MullvadVPN/DeviceManagementContentView.swift b/ios/MullvadVPN/DeviceManagementContentView.swift
new file mode 100644
index 0000000000..f62013bef0
--- /dev/null
+++ b/ios/MullvadVPN/DeviceManagementContentView.swift
@@ -0,0 +1,224 @@
+//
+// DeviceManagementContentView.swift
+// MullvadVPN
+//
+// Created by pronebird on 19/07/2022.
+// Copyright © 2022 Mullvad VPN AB. All rights reserved.
+//
+
+import UIKit
+
+class DeviceManagementContentView: UIView {
+ let statusImageView: StatusImageView = {
+ let imageView = StatusImageView(style: .failure)
+ imageView.translatesAutoresizingMaskIntoConstraints = false
+ return imageView
+ }()
+
+ let titleLabel: UILabel = {
+ let textLabel = UILabel()
+ textLabel.font = UIFont.systemFont(ofSize: 32)
+ textLabel.textColor = .white
+ textLabel.translatesAutoresizingMaskIntoConstraints = false
+ return textLabel
+ }()
+
+ let messageLabel: UILabel = {
+ let textLabel = UILabel()
+ textLabel.font = UIFont.systemFont(ofSize: 17)
+ textLabel.textColor = .white
+ textLabel.translatesAutoresizingMaskIntoConstraints = false
+ textLabel.numberOfLines = 0
+ if #available(iOS 14.0, *) {
+ // See: https://stackoverflow.com/q/46200027/351305
+ textLabel.lineBreakStrategy = []
+ }
+ return textLabel
+ }()
+
+ let continueButton: AppButton = {
+ let button = AppButton(style: .success)
+ button.translatesAutoresizingMaskIntoConstraints = false
+ button.setTitle(
+ NSLocalizedString(
+ "CONTINUE_BUTTON",
+ tableName: "DeviceManagement",
+ value: "Continue with login",
+ comment: ""
+ ),
+ for: .normal
+ )
+ return button
+ }()
+
+ let backButton: AppButton = {
+ let button = AppButton(style: .default)
+ button.translatesAutoresizingMaskIntoConstraints = false
+ button.setTitle(
+ NSLocalizedString(
+ "BACK_BUTTON",
+ tableName: "DeviceManagement",
+ value: "Back",
+ comment: ""
+ ),
+ for: .normal
+ )
+ return button
+ }()
+
+ let deviceStackView: UIStackView = {
+ let stackView = UIStackView(arrangedSubviews: [])
+ stackView.translatesAutoresizingMaskIntoConstraints = false
+ stackView.axis = .vertical
+ stackView.spacing = 1
+ stackView.clipsToBounds = true
+ return stackView
+ }()
+
+ lazy var buttonStackView: UIStackView = {
+ let stackView = UIStackView(arrangedSubviews: [continueButton, backButton])
+ stackView.translatesAutoresizingMaskIntoConstraints = false
+ stackView.axis = .vertical
+ stackView.spacing = UIMetrics.interButtonSpacing
+ return stackView
+ }()
+
+ var canContinue: Bool = false {
+ didSet {
+ updateView()
+ }
+ }
+
+ var handleDeviceDeletion: ((DeviceViewModel, @escaping () -> Void) -> Void)?
+
+ private var currentSnapshot = DataSourceSnapshot<String, String>()
+
+ func setDeviceViewModels(_ newModels: [DeviceViewModel], animated: Bool) {
+ var newSnapshot = DataSourceSnapshot<String, String>()
+ newSnapshot.appendSections([""])
+ newSnapshot.appendItems(newModels.map { $0.id }, in: "")
+
+ let diff = currentSnapshot.difference(newSnapshot)
+ currentSnapshot = newSnapshot
+
+ let applyConfiguration = StackViewApplyDataSnapshotConfiguration { indexPath in
+ let viewModel = newModels[indexPath.row]
+ let view = DeviceRowView(viewModel: viewModel)
+ view.deleteHandler = { [weak self] view in
+ view.showsActivityIndicator = true
+
+ self?.handleDeviceDeletion?(view.viewModel) {
+ view.showsActivityIndicator = false
+ }
+ }
+
+ return view
+ }
+
+ diff.apply(
+ to: deviceStackView,
+ configuration: applyConfiguration,
+ animateDifferences: true
+ )
+ }
+
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+
+ layoutMargins = UIMetrics.contentLayoutMargins
+
+ let spacer = UIView()
+ spacer.translatesAutoresizingMaskIntoConstraints = false
+ spacer.setContentHuggingPriority(.defaultLow - 1, for: .vertical)
+ spacer.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
+
+ let subviewsToAdd = [
+ statusImageView, titleLabel, messageLabel, deviceStackView, spacer, buttonStackView
+ ]
+ for subview in subviewsToAdd {
+ addSubview(subview)
+ }
+
+ updateView()
+
+ NSLayoutConstraint.activate([
+ statusImageView.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor),
+ statusImageView.centerXAnchor.constraint(equalTo: centerXAnchor),
+
+ titleLabel.topAnchor.constraint(equalTo: statusImageView.bottomAnchor, constant: 22),
+ titleLabel.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor),
+ titleLabel.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor),
+
+ messageLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8),
+ messageLabel.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor),
+ messageLabel.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor),
+
+ deviceStackView.topAnchor.constraint(
+ equalTo: messageLabel.bottomAnchor,
+ constant: UIMetrics.sectionSpacing
+ ),
+ deviceStackView.leadingAnchor.constraint(equalTo: leadingAnchor),
+ deviceStackView.trailingAnchor.constraint(equalTo: trailingAnchor),
+
+ spacer.topAnchor.constraint(equalTo: deviceStackView.bottomAnchor),
+ spacer.leadingAnchor.constraint(equalTo: deviceStackView.leadingAnchor),
+ spacer.trailingAnchor.constraint(equalTo: deviceStackView.trailingAnchor),
+ spacer.heightAnchor.constraint(greaterThanOrEqualToConstant: UIMetrics.sectionSpacing),
+
+ buttonStackView.topAnchor.constraint(equalTo: spacer.bottomAnchor),
+ buttonStackView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor),
+ buttonStackView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor),
+ buttonStackView.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor)
+ ])
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ private func updateView() {
+ titleLabel.text = titleText
+ messageLabel.text = messageText
+ statusImageView.style = canContinue ? .success : .failure
+ continueButton.isEnabled = canContinue
+ }
+
+ private var titleText: String {
+ if canContinue {
+ return NSLocalizedString(
+ "CONTINUE_LOGIN_TITLE",
+ tableName: "DeviceManagement",
+ value: "Super!",
+ comment: ""
+ )
+ } else {
+ return NSLocalizedString(
+ "LOGOUT_DEVICES_TITLE",
+ tableName: "DeviceManagement",
+ value: "Too many devices",
+ comment: ""
+ )
+ }
+ }
+
+ private var messageText: String {
+ if canContinue {
+ return NSLocalizedString(
+ "CONTINUE_LOGIN_MESSAGE",
+ tableName: "DeviceManagement",
+ value: "You can now continue logging in on this device.",
+ comment: ""
+ )
+ } else {
+ return NSLocalizedString(
+ "LOGOUT_DEVICES_MESSAGE",
+ tableName: "DeviceManagement",
+ value: """
+ Please log out of at least one by removing it from the list below. You can find \
+ the corresponding device name under the device’s Account settings.
+ """,
+ comment: ""
+ )
+ }
+ }
+}
diff --git a/ios/MullvadVPN/DeviceManagementInteractor.swift b/ios/MullvadVPN/DeviceManagementInteractor.swift
new file mode 100644
index 0000000000..08d7d744be
--- /dev/null
+++ b/ios/MullvadVPN/DeviceManagementInteractor.swift
@@ -0,0 +1,39 @@
+//
+// DeviceManagementInteractor.swift
+// MullvadVPN
+//
+// Created by pronebird on 26/07/2022.
+// Copyright © 2022 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+
+class DeviceManagementInteractor {
+ private let devicesProxy = REST.ProxyFactory.shared.createDevicesProxy()
+ private let accountNumber: String
+
+ init(accountNumber: String) {
+ self.accountNumber = accountNumber
+ }
+
+ @discardableResult
+ func getDevices(_ completionHandler: @escaping (OperationCompletion<[REST.Device], Error>) -> Void) -> Cancellable {
+ return devicesProxy.getDevices(
+ accountNumber: accountNumber,
+ retryStrategy: .default
+ ) { completion in
+ completionHandler(completion.eraseFailureType())
+ }
+ }
+
+ @discardableResult
+ func deleteDevice(_ identifier: String, completionHandler: @escaping (OperationCompletion<Bool, Error>) -> Void) -> Cancellable {
+ return devicesProxy.deleteDevice(
+ accountNumber: accountNumber,
+ identifier: identifier,
+ retryStrategy: .default
+ ) { completion in
+ completionHandler(completion.eraseFailureType())
+ }
+ }
+}
diff --git a/ios/MullvadVPN/DeviceManagementViewController.swift b/ios/MullvadVPN/DeviceManagementViewController.swift
new file mode 100644
index 0000000000..9db683cff4
--- /dev/null
+++ b/ios/MullvadVPN/DeviceManagementViewController.swift
@@ -0,0 +1,277 @@
+//
+// DeviceManagementViewController.swift
+// MullvadVPN
+//
+// Created by pronebird on 15/07/2022.
+// Copyright © 2022 Mullvad VPN AB. All rights reserved.
+//
+
+import UIKit
+import Logging
+
+protocol DeviceManagementViewControllerDelegate: AnyObject {
+ func deviceManagementViewControllerDidFinish(_ controller: DeviceManagementViewController)
+ func deviceManagementViewControllerDidCancel(_ controller: DeviceManagementViewController)
+}
+
+class DeviceManagementViewController: UIViewController, RootContainment {
+ weak var delegate: DeviceManagementViewControllerDelegate?
+
+ var preferredHeaderBarPresentation: HeaderBarPresentation {
+ return .default
+ }
+
+ var prefersHeaderBarHidden: Bool {
+ return false
+ }
+
+ override var preferredStatusBarStyle: UIStatusBarStyle {
+ return .lightContent
+ }
+
+ private let alertPresenter = AlertPresenter()
+
+ private let contentView: DeviceManagementContentView = {
+ let contentView = DeviceManagementContentView()
+ contentView.translatesAutoresizingMaskIntoConstraints = false
+ return contentView
+ }()
+
+ private let logger = Logger(label: "DeviceManagementViewController")
+ private let interactor: DeviceManagementInteractor
+
+ init(interactor: DeviceManagementInteractor) {
+ 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()
+
+ view.backgroundColor = .secondaryColor
+
+ let scrollView = UIScrollView()
+ scrollView.translatesAutoresizingMaskIntoConstraints = false
+ scrollView.addSubview(contentView)
+ view.addSubview(scrollView)
+
+ contentView.backButton.addTarget(
+ self,
+ action: #selector(didTapBackButton(_:)),
+ for: .touchUpInside
+ )
+
+ contentView.continueButton.addTarget(
+ self,
+ action: #selector(didTapContinueButton(_:)),
+ for: .touchUpInside
+ )
+
+ contentView.handleDeviceDeletion = { [weak self] viewModel, finish in
+ self?.handleDeviceDeletion(viewModel, completionHandler: finish)
+ }
+
+ NSLayoutConstraint.activate([
+ scrollView.topAnchor.constraint(equalTo: view.topAnchor),
+ scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
+ scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
+ scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
+
+ contentView.topAnchor.constraint(equalTo: scrollView.topAnchor),
+ contentView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor),
+ contentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
+ contentView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor),
+ contentView.widthAnchor.constraint(equalTo: scrollView.widthAnchor),
+
+ contentView.heightAnchor.constraint(
+ greaterThanOrEqualTo: scrollView.frameLayoutGuide.heightAnchor
+ ),
+ ])
+ }
+
+ func fetchDevices(animateUpdates: Bool, completionHandler: ((OperationCompletion<Void, Error>) -> Void)? = nil) {
+ interactor.getDevices { [weak self] completion in
+ guard let self = self else { return }
+
+ if let devices = completion.value {
+ self.setDevices(devices, animated: animateUpdates)
+ }
+
+ completionHandler?(completion.ignoreOutput())
+ }
+ }
+
+ // MARK: - Private
+
+ private func setDevices(_ devices: [REST.Device], animated: Bool) {
+ let viewModels = devices.map { restDevice -> DeviceViewModel in
+ return DeviceViewModel(
+ id: restDevice.id,
+ name: restDevice.name
+ )
+ }
+
+ contentView.canContinue = viewModels.count < ApplicationConfiguration.maxAllowedDevices
+ contentView.setDeviceViewModels(viewModels, animated: animated)
+ }
+
+ private func handleDeviceDeletion(
+ _ device: DeviceViewModel,
+ completionHandler: @escaping () -> Void
+ ) {
+ showDeleteConfirmation(deviceName: device.displayName) { [weak self] shouldDelete in
+ guard let self = self else { return }
+
+ guard shouldDelete else {
+ completionHandler()
+ return
+ }
+
+ self.deleteDevice(identifier: device.id) { error in
+ if let error = error {
+ self.showErrorAlert(
+ title: NSLocalizedString(
+ "LOGOUT_DEVICE_ERROR_ALERT_TITLE",
+ tableName: "DeviceManagement",
+ value: "Failed to log out device",
+ comment: ""
+ ),
+ error: error
+ )
+ }
+
+ completionHandler()
+ }
+ }
+ }
+
+ private func getErrorDescription(_ error: Error) -> String {
+ if case .network(let urlError) = error as? REST.Error {
+ return urlError.localizedDescription
+ } else {
+ return error.localizedDescription
+ }
+ }
+
+ private func showErrorAlert(title: String, error: Error) {
+ let alertController = UIAlertController(
+ title: title,
+ message: getErrorDescription(error),
+ preferredStyle: .alert
+ )
+
+ alertController.addAction(
+ UIAlertAction(
+ title: NSLocalizedString(
+ "ERROR_ALERT_OK_ACTION",
+ tableName: "DeviceManagement",
+ value: "OK",
+ comment: ""
+ ),
+ style: .cancel
+ )
+ )
+
+ alertPresenter.enqueue(alertController, presentingController: self)
+ }
+
+ private func showDeleteConfirmation(
+ deviceName: String,
+ completion: @escaping (_ shouldDelete: Bool) -> Void
+ ) {
+ let localizedTitle = String(
+ format: NSLocalizedString(
+ "DELETE_ALERT_TITLE",
+ tableName: "DeviceManagement",
+ value: "Are you sure you want to log out of %@?",
+ comment: ""
+ ), deviceName
+ )
+
+ let alertController = UIAlertController(
+ title: localizedTitle,
+ message: nil,
+ preferredStyle: .alert
+ )
+
+ let actions = [
+ UIAlertAction(
+ title: NSLocalizedString(
+ "DELETE_ALERT_CANCEL_ACTION",
+ tableName: "DeviceManagement",
+ value: "Back",
+ comment: ""
+ ),
+ style: .cancel,
+ handler: { _ in
+ completion(false)
+ }
+ ),
+ UIAlertAction(
+ title: NSLocalizedString(
+ "DELETE_ALERT_CONFIRM_ACTION",
+ tableName: "DeviceManagement",
+ value: "Yes, log out device",
+ comment: ""
+ ),
+ style: .destructive,
+ handler: { _ in
+ completion(true)
+ }
+ )
+ ]
+
+ for action in actions {
+ alertController.addAction(action)
+ }
+
+ alertPresenter.enqueue(alertController, presentingController: self)
+ }
+
+ private func deleteDevice(identifier: String, completionHandler: @escaping (Error?) -> Void) {
+ interactor.deleteDevice(identifier) { [weak self] completion in
+ guard let self = self else { return }
+
+ switch completion {
+ case .success:
+ self.fetchDevices(animateUpdates: true) { completion in
+ completionHandler(completion.error)
+ }
+
+ case .failure(let error):
+ self.logger.error(
+ chainedError: AnyChainedError(error),
+ message: "Failed to delete device."
+ )
+ completionHandler(error)
+
+ case .cancelled:
+ completionHandler(nil)
+ }
+ }
+ }
+
+ // MARK: - Actions
+
+ @objc private func didTapBackButton(_ sender: Any?) {
+ delegate?.deviceManagementViewControllerDidCancel(self)
+ }
+
+ @objc private func didTapContinueButton(_ sender: Any?) {
+ delegate?.deviceManagementViewControllerDidFinish(self)
+ }
+}
+
+struct DeviceViewModel {
+ var id: String
+ var name: String
+
+ var displayName: String {
+ return name.capitalized
+ }
+}
diff --git a/ios/MullvadVPN/DeviceRowView.swift b/ios/MullvadVPN/DeviceRowView.swift
new file mode 100644
index 0000000000..bca1830ccd
--- /dev/null
+++ b/ios/MullvadVPN/DeviceRowView.swift
@@ -0,0 +1,111 @@
+//
+// DeviceRowView.swift
+// MullvadVPN
+//
+// Created by pronebird on 26/07/2022.
+// Copyright © 2022 Mullvad VPN AB. All rights reserved.
+//
+
+import UIKit
+
+class DeviceRowView: UIView {
+ let viewModel: DeviceViewModel
+ var deleteHandler: ((DeviceRowView) -> Void)?
+
+ let textLabel: UILabel = {
+ let textLabel = UILabel()
+ textLabel.translatesAutoresizingMaskIntoConstraints = false
+ textLabel.font = UIFont.systemFont(ofSize: 17)
+ textLabel.textColor = .white
+ return textLabel
+ }()
+
+ let activityIndicator: SpinnerActivityIndicatorView = {
+ let activityIndicator = SpinnerActivityIndicatorView(style: .custom)
+ activityIndicator.translatesAutoresizingMaskIntoConstraints = false
+ return activityIndicator
+ }()
+
+ let removeButton: UIButton = {
+ let image = UIImage(named: "IconClose")?
+ .backport_withTintColor(
+ .white.withAlphaComponent(0.4),
+ renderingMode: .alwaysOriginal
+ )
+
+ let button = UIButton(type: .custom)
+ button.translatesAutoresizingMaskIntoConstraints = false
+ button.setImage(image, for: .normal)
+ button.accessibilityLabel = NSLocalizedString(
+ "REMOVE_DEVICE_ACCESSIBILITY_LABEL",
+ tableName: "DeviceManagement",
+ value: "Remove device",
+ comment: ""
+ )
+ return button
+ }()
+
+ var showsActivityIndicator: Bool = false {
+ didSet {
+ removeButton.isHidden = showsActivityIndicator
+
+ if showsActivityIndicator {
+ activityIndicator.startAnimating()
+ } else {
+ activityIndicator.stopAnimating()
+ }
+ }
+ }
+
+ init(viewModel: DeviceViewModel) {
+ self.viewModel = viewModel
+
+ super.init(frame: .zero)
+
+ backgroundColor = .primaryColor
+ layoutMargins = UIMetrics.rowViewLayoutMargins
+
+ for subview in [textLabel, removeButton, activityIndicator] {
+ addSubview(subview)
+ }
+
+ textLabel.text = viewModel.displayName
+
+ removeButton.addTarget(self, action: #selector(handleButtonTap(_:)), for: .touchUpInside)
+
+ NSLayoutConstraint.activate([
+ textLabel.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor),
+ textLabel.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor),
+ textLabel.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor)
+ .withPriority(.defaultLow),
+
+ removeButton.centerYAnchor.constraint(equalTo: textLabel.centerYAnchor),
+ removeButton.leadingAnchor.constraint(
+ greaterThanOrEqualTo: textLabel.trailingAnchor,
+ constant: 8
+ ),
+ removeButton.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor),
+
+ activityIndicator.centerXAnchor.constraint(equalTo: removeButton.centerXAnchor),
+ activityIndicator.centerYAnchor.constraint(equalTo: removeButton.centerYAnchor),
+
+ // Bump dimensions by 6pt to account for transparent pixels around spinner image.
+ activityIndicator.widthAnchor.constraint(
+ equalTo: removeButton.widthAnchor,
+ constant: 6
+ ),
+ activityIndicator.heightAnchor.constraint(
+ equalTo: removeButton.heightAnchor,
+ constant: 6
+ ),
+ ])
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ @objc private func handleButtonTap(_ sender: Any?) {
+ deleteHandler?(self)
+ }
+}
diff --git a/ios/MullvadVPN/LoginViewController.swift b/ios/MullvadVPN/LoginViewController.swift
index 0b8213dfd6..b6e9a3df71 100644
--- a/ios/MullvadVPN/LoginViewController.swift
+++ b/ios/MullvadVPN/LoginViewController.swift
@@ -9,30 +9,35 @@
import UIKit
import Logging
-enum AuthenticationMethod {
- case existingAccount, newAccount
+enum LoginAction {
+ case useExistingAccount(String)
+ case createAccount
+
+ var setAccountAction: SetAccountAction {
+ switch self {
+ case .useExistingAccount(let accountNumber):
+ return .existing(accountNumber)
+ case .createAccount:
+ return .new
+ }
+ }
}
enum LoginState {
case `default`
- case authenticating(AuthenticationMethod)
+ case authenticating(LoginAction)
case failure(Error)
- case success(AuthenticationMethod)
+ case success(LoginAction)
}
protocol LoginViewControllerDelegate: AnyObject {
func loginViewController(
_ controller: LoginViewController,
- loginWithAccountToken accountToken: String,
- completion: @escaping (OperationCompletion<StoredAccountData?, Error>) -> Void
- )
-
- func loginViewControllerLoginWithNewAccount(
- _ controller: LoginViewController,
+ shouldHandleLoginAction action: LoginAction,
completion: @escaping (OperationCompletion<StoredAccountData?, Error>) -> Void
)
- func loginViewControllerDidLogin(_ controller: LoginViewController)
+ func loginViewControllerDidFinishLogin(_ controller: LoginViewController)
}
class LoginViewController: UIViewController, RootContainment {
@@ -156,6 +161,25 @@ class LoginViewController: UIViewController, RootContainment {
// MARK: - Public
+ func start(action: LoginAction) {
+ beginLogin(action)
+
+ delegate?.loginViewController(self, shouldHandleLoginAction: action) { [weak self] completion in
+ switch completion {
+ case .success(let accountData):
+ if case .createAccount = action {
+ self?.contentView.accountInputGroup.setAccount(accountData?.number ?? "")
+ }
+
+ self?.endLogin(.success(action))
+ case .failure(let error):
+ self?.endLogin(.failure(error))
+ case .cancelled:
+ self?.endLogin(.default)
+ }
+ }
+ }
+
func reset() {
contentView.accountInputGroup.clearAccount()
loginState = .default
@@ -180,43 +204,18 @@ class LoginViewController: UIViewController, RootContainment {
// MARK: - Actions
- @objc func cancelLogin() {
+ @objc private func cancelLogin() {
view.endEditing(true)
}
- @objc func doLogin() {
- let accountToken = contentView.accountInputGroup.parsedToken
+ @objc private func doLogin() {
+ let accountNumber = contentView.accountInputGroup.parsedToken
- beginLogin(method: .existingAccount)
- self.delegate?.loginViewController(self, loginWithAccountToken: accountToken, completion: { [weak self] completion in
- switch completion {
- case .success:
- self?.endLogin(.success(.existingAccount))
- case .failure(let error):
- self?.endLogin(.failure(error))
- case .cancelled:
- self?.endLogin(.default)
- }
- })
+ start(action: .useExistingAccount(accountNumber))
}
- @objc func createNewAccount() {
- beginLogin(method: .newAccount)
-
- contentView.accountInputGroup.clearAccount()
- updateKeyboardToolbar()
-
- self.delegate?.loginViewControllerLoginWithNewAccount(self, completion: { [weak self] completion in
- switch completion {
- case .success(let accountData):
- self?.contentView.accountInputGroup.setAccount(accountData?.number ?? "")
- self?.endLogin(.success(.newAccount))
- case .failure(let error):
- self?.endLogin(.failure(error))
- case .cancelled:
- self?.endLogin(.default)
- }
- })
+ @objc private func createNewAccount() {
+ start(action: .createAccount)
}
// MARK: - Private
@@ -261,8 +260,8 @@ class LoginViewController: UIViewController, RootContainment {
}
}
- private func beginLogin(method: AuthenticationMethod) {
- loginState = .authenticating(method)
+ private func beginLogin(_ action: LoginAction) {
+ loginState = .authenticating(action)
view.endEditing(true)
}
@@ -272,12 +271,12 @@ class LoginViewController: UIViewController, RootContainment {
loginState = nextLoginState
- if case .authenticating(.existingAccount) = oldLoginState, case .failure = loginState {
+ if case .authenticating(.useExistingAccount) = oldLoginState, case .failure = loginState {
contentView.accountInputGroup.textField.becomeFirstResponder()
} else if case .success = loginState {
// Navigate to the main view after 1s delay
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) {
- self.delegate?.loginViewControllerDidLogin(self)
+ self.delegate?.loginViewControllerDidFinishLogin(self)
}
}
}
@@ -372,14 +371,14 @@ private extension LoginState {
case .authenticating(let method):
switch method {
- case .existingAccount:
+ case .useExistingAccount:
return NSLocalizedString(
"SUBHEAD_TITLE_AUTHENTICATING",
tableName: "Login",
value: "Checking account number",
comment: ""
)
- case .newAccount:
+ case .createAccount:
return NSLocalizedString(
"SUBHEAD_TITLE_CREATING_ACCOUNT",
tableName: "Login",
@@ -393,14 +392,14 @@ private extension LoginState {
case .success(let method):
switch method {
- case .existingAccount:
+ case .useExistingAccount:
return NSLocalizedString(
"SUBHEAD_TITLE_SUCCESS",
tableName: "Login",
value: "Correct account number",
comment: ""
)
- case .newAccount:
+ case .createAccount:
return NSLocalizedString(
"SUBHEAD_TITLE_CREATED_ACCOUNT",
tableName: "Login",
@@ -420,7 +419,7 @@ extension LoginViewController: AccountInputGroupViewDelegate {
try SettingsManager.setLastUsedAccount(nil)
return true
} catch {
- self.logger.error(chainedError: AnyChainedError(error),
+ logger.error(chainedError: AnyChainedError(error),
message: "Failed to remove last used account.")
return false
}
diff --git a/ios/MullvadVPN/REST/RESTDevicesProxy.swift b/ios/MullvadVPN/REST/RESTDevicesProxy.swift
index 5a1fa115fd..7ee34c0a8e 100644
--- a/ios/MullvadVPN/REST/RESTDevicesProxy.swift
+++ b/ios/MullvadVPN/REST/RESTDevicesProxy.swift
@@ -30,7 +30,7 @@ extension REST {
accountNumber: String,
identifier: String,
retryStrategy: REST.RetryStrategy,
- completion: @escaping CompletionHandler<Device?>
+ completion: @escaping CompletionHandler<Device>
) -> Cancellable
{
let requestHandler = AnyRequestHandler(
@@ -59,19 +59,14 @@ extension REST {
)
)
- let responseHandler = AnyResponseHandler { response, data -> ResponseHandlerResult<Device?> in
+ let responseHandler = AnyResponseHandler { response, data -> ResponseHandlerResult<Device> in
let httpStatus = HTTPStatus(rawValue: response.statusCode)
- switch httpStatus {
- case let httpStatus where httpStatus.isSuccess:
+ if httpStatus.isSuccess {
return .decoding {
return try self.responseDecoder.decode(Device.self, from: data)
}
-
- case .notFound:
- return .success(nil)
-
- default:
+ } else {
return .unhandledResponse(
try? self.responseDecoder.decode(
ServerErrorResponse.self,
diff --git a/ios/MullvadVPN/REST/RESTError.swift b/ios/MullvadVPN/REST/RESTError.swift
index 175370563f..44db332050 100644
--- a/ios/MullvadVPN/REST/RESTError.swift
+++ b/ios/MullvadVPN/REST/RESTError.swift
@@ -34,11 +34,11 @@ extension REST {
var str = "Failure to handle server response: HTTP/\(statusCode)."
if let code = serverResponse?.code {
- str += " Error code: \(code)."
+ str += " Error code: \(code.rawValue)."
}
if let detail = serverResponse?.detail {
- str += " Detail: \(detail)."
+ str += " Detail: \(detail)"
}
return str
diff --git a/ios/MullvadVPN/REST/SSLPinningURLSessionDelegate.swift b/ios/MullvadVPN/REST/SSLPinningURLSessionDelegate.swift
index de119e220b..50b0176288 100644
--- a/ios/MullvadVPN/REST/SSLPinningURLSessionDelegate.swift
+++ b/ios/MullvadVPN/REST/SSLPinningURLSessionDelegate.swift
@@ -50,14 +50,14 @@ class SSLPinningURLSessionDelegate: NSObject, URLSessionDelegate {
// Set trusted root certificates
secResult = SecTrustSetAnchorCertificates(serverTrust, trustedRootCertificates as CFArray)
guard secResult == errSecSuccess else {
- self.logger.error("SecTrustSetAnchorCertificates failure: \(self.formatErrorMessage(code: secResult))")
+ logger.error("SecTrustSetAnchorCertificates failure: \(self.formatErrorMessage(code: secResult))")
return false
}
// Tell security framework to only trust the provided root certificates
secResult = SecTrustSetAnchorCertificatesOnly(serverTrust, true)
guard secResult == errSecSuccess else {
- self.logger.error("SecTrustSetAnchorCertificatesOnly failure: \(self.formatErrorMessage(code: secResult))")
+ logger.error("SecTrustSetAnchorCertificatesOnly failure: \(self.formatErrorMessage(code: secResult))")
return false
}
@@ -65,7 +65,7 @@ class SSLPinningURLSessionDelegate: NSObject, URLSessionDelegate {
if SecTrustEvaluateWithError(serverTrust, &error) {
return true
} else {
- self.logger.error("SecTrustEvaluateWithError failure: \(error?.localizedDescription ?? "<nil>")")
+ logger.error("SecTrustEvaluateWithError failure: \(error?.localizedDescription ?? "<nil>")")
return false
}
}
diff --git a/ios/MullvadVPN/REST/ServerRelaysResponse.swift b/ios/MullvadVPN/REST/ServerRelaysResponse.swift
index f2f6baa4ad..a5606510dc 100644
--- a/ios/MullvadVPN/REST/ServerRelaysResponse.swift
+++ b/ios/MullvadVPN/REST/ServerRelaysResponse.swift
@@ -18,7 +18,7 @@ extension REST {
let longitude: Double
}
- struct ServerRelay: Codable, Equatable {
+ struct ServerRelay: Codable {
let hostname: String
let active: Bool
let owned: Bool
diff --git a/ios/MullvadVPN/RootContainerViewController.swift b/ios/MullvadVPN/RootContainerViewController.swift
index f98c3e93df..35d48ef157 100644
--- a/ios/MullvadVPN/RootContainerViewController.swift
+++ b/ios/MullvadVPN/RootContainerViewController.swift
@@ -189,6 +189,15 @@ class RootContainerViewController: UIViewController {
setViewControllersInternal(newViewControllers, isUnwinding: false, animated: animated, completion: completion)
}
+ func popViewController(animated: Bool, completion: CompletionHandler? = nil) {
+ guard viewControllers.count > 1 else { return }
+
+ var newViewControllers = viewControllers
+ newViewControllers.removeLast()
+
+ setViewControllersInternal(newViewControllers, isUnwinding: true, animated: animated, completion: completion)
+ }
+
func popToRootViewController(animated: Bool, completion: CompletionHandler? = nil) {
if let rootController = self.viewControllers.first, self.viewControllers.count > 1 {
setViewControllersInternal([rootController], isUnwinding: true, animated: animated, completion: completion)
diff --git a/ios/MullvadVPN/SceneDelegate.swift b/ios/MullvadVPN/SceneDelegate.swift
index 3d134115e1..5fe4dcbc52 100644
--- a/ios/MullvadVPN/SceneDelegate.swift
+++ b/ios/MullvadVPN/SceneDelegate.swift
@@ -26,6 +26,7 @@ class SceneDelegate: UIResponder {
private var selectLocationViewController: SelectLocationViewController?
private var connectController: ConnectViewController?
private weak var settingsNavController: SettingsNavigationController?
+ private var lastLoginAction: LoginAction?
override init() {
super.init()
@@ -481,62 +482,64 @@ extension SceneDelegate {
extension SceneDelegate: LoginViewControllerDelegate {
- func loginViewController(_ controller: LoginViewController, loginWithAccountToken accountNumber: String, completion: @escaping (OperationCompletion<StoredAccountData?, Error>) -> Void) {
- rootContainer.setEnableSettingsButton(false)
+ func loginViewController(
+ _ controller: LoginViewController,
+ shouldHandleLoginAction action: LoginAction,
+ completion: @escaping (OperationCompletion<StoredAccountData?, Error>) -> Void
+ ) {
+ setEnableSettingsButton(isEnabled: false, from: controller)
- TunnelManager.shared.setAccount(action: .existing(accountNumber)) { operationCompletion in
+ TunnelManager.shared.setAccount(action: action.setAccountAction) { operationCompletion in
switch operationCompletion {
case .success:
- self.logger.debug("Logged in with existing account.")
- // RootContainer's settings button will be re-enabled in `loginViewControllerDidLogin`
+ // RootContainer's settings button will be re-enabled in `loginViewControllerDidFinishLogin`
+ completion(operationCompletion)
case .failure(let error):
- self.logger.error(
- chainedError: AnyChainedError(error),
- message: "Failed to log in with existing account."
- )
- fallthrough
-
- case .cancelled:
- self.rootContainer.setEnableSettingsButton(true)
- }
+ // Show device management controller when too many devices detected during log in.
+ if case .useExistingAccount(let accountNumber) = action,
+ let restError = error as? REST.Error,
+ restError.compareErrorCode(.maxDevicesReached)
+ {
+ self.lastLoginAction = action
- completion(operationCompletion)
- }
- }
+ let deviceController = DeviceManagementViewController(
+ interactor: DeviceManagementInteractor(accountNumber: accountNumber)
+ )
+ deviceController.delegate = self
- func loginViewControllerLoginWithNewAccount(_ controller: LoginViewController, completion: @escaping (OperationCompletion<StoredAccountData?, Error>) -> Void) {
- rootContainer.setEnableSettingsButton(false)
+ deviceController.fetchDevices(animateUpdates: false) { [weak self] operationCompletion in
+ controller.rootContainerController?.pushViewController(
+ deviceController,
+ animated: true
+ )
- TunnelManager.shared.setAccount(action: .new) { operationCompletion in
- switch operationCompletion {
- case .success:
- self.logger.debug("Logged in with new account number.")
- // RootContainer's settings button will be re-enabled in `loginViewControllerDidLogin`
+ // Return .cancelled to login controller upon success.
+ completion(operationCompletion.flatMap { .cancelled })
- case .failure(let error):
- self.logger.error(
- chainedError: AnyChainedError(error),
- message: "Failed to log in with new account."
- )
- fallthrough
+ self?.setEnableSettingsButton(isEnabled: true, from: controller)
+ }
+ } else {
+ fallthrough
+ }
case .cancelled:
- self.rootContainer.setEnableSettingsButton(true)
+ self.setEnableSettingsButton(isEnabled: true, from: controller)
+ completion(operationCompletion)
}
- completion(operationCompletion)
}
}
- func loginViewControllerDidLogin(_ controller: LoginViewController) {
- window?.isUserInteractionEnabled = false
+ func loginViewControllerDidFinishLogin(_ controller: LoginViewController) {
+ self.lastLoginAction = nil
// Move the settings button back into header bar
rootContainer.removeSettingsButtonFromPresentationContainer()
+ setEnableSettingsButton(isEnabled: true, from: controller)
let relayConstraints = TunnelManager.shared.settings.relayConstraints
- self.selectLocationViewController?.setSelectedRelayLocation(
+ selectLocationViewController?.setSelectedRelayLocation(
relayConstraints.location.value,
animated: false,
scrollPosition: .middle
@@ -558,13 +561,37 @@ extension SceneDelegate: LoginViewControllerDelegate {
default:
fatalError()
}
+ }
- window?.isUserInteractionEnabled = true
- rootContainer.setEnableSettingsButton(true)
+ private func setEnableSettingsButton(isEnabled: Bool, from viewController: UIViewController?) {
+ let containers = [viewController?.rootContainerController, rootContainer].compactMap { $0 }
+
+ for container in Set(containers) {
+ container.setEnableSettingsButton(isEnabled)
+ }
}
}
+// MARK: - DeviceManagementViewControllerDelegate
+
+extension SceneDelegate: DeviceManagementViewControllerDelegate {
+ func deviceManagementViewControllerDidCancel(_ controller: DeviceManagementViewController) {
+ controller.rootContainerController?.popViewController(animated: true)
+ }
+
+ func deviceManagementViewControllerDidFinish(_ controller: DeviceManagementViewController) {
+ let currentRootContainer = controller.rootContainerController
+ let loginViewController = currentRootContainer?.viewControllers.first as? LoginViewController
+
+ currentRootContainer?.popViewController(animated: true) {
+ if let lastLoginAction = self.lastLoginAction {
+ loginViewController?.start(action: lastLoginAction)
+ }
+ }
+ }
+}
+
// MARK: - SettingsNavigationControllerDelegate
extension SceneDelegate: SettingsNavigationControllerDelegate {
diff --git a/ios/MullvadVPN/SelectLocationCell.swift b/ios/MullvadVPN/SelectLocationCell.swift
index 436eeb4b6b..eb52175cd4 100644
--- a/ios/MullvadVPN/SelectLocationCell.swift
+++ b/ios/MullvadVPN/SelectLocationCell.swift
@@ -141,7 +141,8 @@ class SelectLocationCell: UITableViewCell {
statusIndicator.centerYAnchor.constraint(equalTo: tickImageView.centerYAnchor),
locationLabel.leadingAnchor.constraint(equalTo: statusIndicator.trailingAnchor, constant: 12),
- locationLabel.trailingAnchor.constraint(lessThanOrEqualTo: collapseButton.leadingAnchor),
+ locationLabel.trailingAnchor.constraint(lessThanOrEqualTo: collapseButton.leadingAnchor)
+ .withPriority(.defaultHigh),
locationLabel.topAnchor.constraint(equalTo: contentView.layoutMarginsGuide.topAnchor),
locationLabel.bottomAnchor.constraint(equalTo: contentView.layoutMarginsGuide.bottomAnchor),
diff --git a/ios/MullvadVPN/SelectLocationViewController.swift b/ios/MullvadVPN/SelectLocationViewController.swift
index b5ef62af49..cf27d8474e 100644
--- a/ios/MullvadVPN/SelectLocationViewController.swift
+++ b/ios/MullvadVPN/SelectLocationViewController.swift
@@ -23,7 +23,6 @@ class SelectLocationViewController: UIViewController, UITableViewDelegate {
private var tableHeaderFooterViewTopConstraints: [NSLayoutConstraint] = []
private var tableHeaderFooterViewBottomConstraints: [NSLayoutConstraint] = []
- private let logger = Logger(label: "SelectLocationController")
private var dataSource: LocationDataSource?
private var setCachedRelaysOnViewDidLoad: RelayCache.CachedRelays?
private var setRelayLocationOnViewDidLoad: RelayLocation?
diff --git a/ios/MullvadVPN/TunnelManager/TunnelManager.swift b/ios/MullvadVPN/TunnelManager/TunnelManager.swift
index 0b1236f743..03f095c9af 100644
--- a/ios/MullvadVPN/TunnelManager/TunnelManager.swift
+++ b/ios/MullvadVPN/TunnelManager/TunnelManager.swift
@@ -139,10 +139,6 @@ final class TunnelManager {
return nil
}
- if completion.error is RevokedDeviceError {
- return nil
- }
-
// Do not rotate the key if account or device is not found.
if let restError = completion.error as? REST.Error,
restError.compareErrorCode(.invalidAccount) ||
@@ -417,9 +413,12 @@ final class TunnelManager {
operation.completionQueue = .main
operation.completionHandler = { [weak self] completion in
- if completion.error is RevokedDeviceError {
- self?.didDetectDeviceRevoked()
+ guard let self = self else { return }
+
+ if let error = completion.error {
+ self.checkIfDeviceRevoked(error)
}
+
completionHandler(completion)
}
@@ -465,12 +464,9 @@ final class TunnelManager {
}
case .failure(let error):
- self.logger.error(
- chainedError: AnyChainedError(error),
- message: "Failed to rotate private key."
- )
+ self.checkIfDeviceRevoked(error)
- completionHandler(completion)
+ completionHandler(.failure(error))
case .cancelled:
completionHandler(completion)
@@ -726,6 +722,12 @@ final class TunnelManager {
lastMapConnectionStatusOperation = nil
}
+ private func checkIfDeviceRevoked(_ error: Error) {
+ if let error = error as? REST.Error, error.compareErrorCode(.deviceNotFound) {
+ didDetectDeviceRevoked()
+ }
+ }
+
private func didDetectDeviceRevoked() {
scheduleDeviceStateUpdate(
taskName: "Set device revoked",
diff --git a/ios/MullvadVPN/TunnelManager/TunnelManagerErrors.swift b/ios/MullvadVPN/TunnelManager/TunnelManagerErrors.swift
index 77c09cdc9c..8c25b173df 100644
--- a/ios/MullvadVPN/TunnelManager/TunnelManagerErrors.swift
+++ b/ios/MullvadVPN/TunnelManager/TunnelManagerErrors.swift
@@ -31,17 +31,6 @@ struct InvalidDeviceStateError: LocalizedError {
}
}
-struct RevokedDeviceError: LocalizedError {
- var errorDescription: String? {
- return NSLocalizedString(
- "REVOKED_DEVICE_ERROR",
- tableName: "TunnelManager",
- value: "Device is revoked.",
- comment: ""
- )
- }
-}
-
struct StartTunnelError: LocalizedError {
var errorDescription: String? {
return NSLocalizedString(
diff --git a/ios/MullvadVPN/TunnelManager/UpdateDeviceDataOperation.swift b/ios/MullvadVPN/TunnelManager/UpdateDeviceDataOperation.swift
index 3c7968371f..af20dbc2a1 100644
--- a/ios/MullvadVPN/TunnelManager/UpdateDeviceDataOperation.swift
+++ b/ios/MullvadVPN/TunnelManager/UpdateDeviceDataOperation.swift
@@ -52,25 +52,22 @@ class UpdateDeviceDataOperation: ResultOperation<StoredDeviceData, Error> {
task = nil
}
- private func didReceiveDeviceResponse(completion: OperationCompletion<REST.Device?, REST.Error>)
+ private func didReceiveDeviceResponse(completion: OperationCompletion<REST.Device, REST.Error>)
{
- let mappedCompletion = completion.tryMap { device -> StoredDeviceData in
- guard let device = device else {
- throw RevokedDeviceError()
- }
-
- switch interactor.deviceState {
- case .loggedIn(let storedAccount, var storedDevice):
- storedDevice.update(from: device)
- let newDeviceState = DeviceState.loggedIn(storedAccount, storedDevice)
- interactor.setDeviceState(newDeviceState, persist: true)
+ let mappedCompletion = completion
+ .tryMap { device -> StoredDeviceData in
+ switch interactor.deviceState {
+ case .loggedIn(let storedAccount, var storedDevice):
+ storedDevice.update(from: device)
+ let newDeviceState = DeviceState.loggedIn(storedAccount, storedDevice)
+ interactor.setDeviceState(newDeviceState, persist: true)
- return storedDevice
+ return storedDevice
- default:
- throw InvalidDeviceStateError()
+ default:
+ throw InvalidDeviceStateError()
+ }
}
- }
finish(completion: mappedCompletion)
}
diff --git a/ios/MullvadVPN/UIMetrics.swift b/ios/MullvadVPN/UIMetrics.swift
index 7085af4874..db45331197 100644
--- a/ios/MullvadVPN/UIMetrics.swift
+++ b/ios/MullvadVPN/UIMetrics.swift
@@ -15,6 +15,10 @@ extension UIMetrics {
/// Common layout margins for content presentation
static let contentLayoutMargins = UIEdgeInsets(top: 24, left: 24, bottom: 24, right: 24)
+ /// Common layout margins for row views presentation
+ /// Similar to `settingsCellLayoutMargins` however maintains equal horizontal spacing
+ static let rowViewLayoutMargins = UIEdgeInsets(top: 16, left: 24, bottom: 16, right: 24)
+
/// Common layout margins for settings cell presentation
static let settingsCellLayoutMargins = UIEdgeInsets(top: 16, left: 24, bottom: 16, right: 12)
diff --git a/ios/MullvadVPN/WireguardKeysViewController.swift b/ios/MullvadVPN/WireguardKeysViewController.swift
index 0e29a11d64..a177519483 100644
--- a/ios/MullvadVPN/WireguardKeysViewController.swift
+++ b/ios/MullvadVPN/WireguardKeysViewController.swift
@@ -247,7 +247,8 @@ class WireguardKeysViewController: UIViewController, TunnelObserver {
self.updateViewState(.verifiedKey(true))
case .failure(let error):
- if error is RevokedDeviceError {
+ if let restError = error as? REST.Error,
+ restError.compareErrorCode(.deviceNotFound) {
self.updateViewState(.verifiedKey(false))
} else {
self.showKeyVerificationFailureAlert(error)
@@ -255,7 +256,7 @@ class WireguardKeysViewController: UIViewController, TunnelObserver {
}
case .cancelled:
- break
+ self.updateViewState(.default)
}
}
}
@@ -264,11 +265,23 @@ class WireguardKeysViewController: UIViewController, TunnelObserver {
self.updateViewState(.regeneratingKey)
_ = TunnelManager.shared.rotatePrivateKey(forceRotate: true) { [weak self] completion in
- if let error = completion.error {
- self?.showKeyRegenerationFailureAlert(error)
- self?.updateViewState(.regeneratedKey(false))
- } else {
- self?.updateViewState(.regeneratedKey(true))
+ guard let self = self else { return }
+
+ switch completion {
+ case .success:
+ self.updateViewState(.regeneratedKey(true))
+
+ case .failure(let error):
+ if let restError = error as? REST.Error,
+ restError.compareErrorCode(.deviceNotFound) {
+ self.updateViewState(.regeneratedKey(false))
+ } else {
+ self.showKeyRegenerationFailureAlert(error)
+ self.updateViewState(.default)
+ }
+
+ case .cancelled:
+ self.updateViewState(.default)
}
}
}
diff --git a/ios/convert-assets.rb b/ios/convert-assets.rb
index 5a26616e9e..d576722b52 100755
--- a/ios/convert-assets.rb
+++ b/ios/convert-assets.rb
@@ -32,6 +32,7 @@ GRAPHICAL_ASSETS = [
"location-marker-unsecure.svg",
"logo-icon.svg",
"logo-text.svg",
+ "icon-close.svg",
"icon-close-sml.svg",
"icon-copy.svg",
"icon-obscure.svg",