diff options
| author | Andrej Mihajlov <and@mullvad.net> | 2022-08-02 13:58:40 +0200 |
|---|---|---|
| committer | Andrej Mihajlov <and@mullvad.net> | 2022-08-02 13:58:40 +0200 |
| commit | 0523a5131466de2b2f8f10f46dbf1ab074c58c1c (patch) | |
| tree | 478dd87cba6c6ee93daf0756f409c01a49978f87 | |
| parent | a3df757018bc0b41036d2ec710819b16be356bb5 (diff) | |
| parent | af87af81eaae462c35ede3722c381b26372778eb (diff) | |
| download | mullvadvpn-0523a5131466de2b2f8f10f46dbf1ab074c58c1c.tar.xz mullvadvpn-0523a5131466de2b2f8f10f46dbf1ab074c58c1c.zip | |
Merge branch 'add-device-mgmt'
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 Binary files differnew file mode 100644 index 0000000000..cc53916273 --- /dev/null +++ b/ios/MullvadVPN/Assets.xcassets/IconClose.imageset/IconClose.pdf 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", |
