diff options
| author | Steffen <steffen.ernst@mullvad.net> | 2025-06-03 10:54:05 +0200 |
|---|---|---|
| committer | Jon Petersson <jon.petersson@mullvad.net> | 2025-06-23 11:07:40 +0200 |
| commit | ce6fb13d38ce48e33a42b65ed7f506dc414e4f8b (patch) | |
| tree | c5f7d3cf790b9da2ce4a8bbc80002b38a4d965db | |
| parent | 9cc76d2df81dab2d757478e499fd1f28b0195b3f (diff) | |
| download | mullvadvpn-ce6fb13d38ce48e33a42b65ed7f506dc414e4f8b.tar.xz mullvadvpn-ce6fb13d38ce48e33a42b65ed7f506dc414e4f8b.zip | |
Add device management view
26 files changed, 1094 insertions, 886 deletions
diff --git a/ios/CHANGELOG.md b/ios/CHANGELOG.md index 6304950f65..1b681ad1ac 100644 --- a/ios/CHANGELOG.md +++ b/ios/CHANGELOG.md @@ -25,6 +25,7 @@ Line wrap the file at 100 chars. Th ### Added - Make feature indicators clickable shortcuts to their corresponding settings. - Let users cancel sending a problem report. +- Add possibility to manage devices from account view. ### Changed - Replace Classic McEliece with HQC as one of the post-quantum safe key exchange diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index 47b1b7f97f..33c22cf023 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -116,8 +116,7 @@ 581F23AD2A8CF92100788AB6 /* DefaultPathObserverFake.swift in Sources */ = {isa = PBXBuildFile; fileRef = 581F23AC2A8CF92100788AB6 /* DefaultPathObserverFake.swift */; }; 581F23AF2A8CF94D00788AB6 /* PingerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 581F23AE2A8CF94D00788AB6 /* PingerMock.swift */; }; 5820676426E771DB00655B05 /* TunnelManagerErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5820676326E771DB00655B05 /* TunnelManagerErrors.swift */; }; - 5820EDA9288FE064006BF4E4 /* DeviceManagementInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5820EDA8288FE064006BF4E4 /* DeviceManagementInteractor.swift */; }; - 5820EDAB288FF0D2006BF4E4 /* DeviceRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5820EDAA288FF0D2006BF4E4 /* DeviceRowView.swift */; }; + 5820EDA9288FE064006BF4E4 /* DeviceManaging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5820EDA8288FE064006BF4E4 /* DeviceManaging.swift */; }; 58238CB92AD57EC700768310 /* MullvadREST.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 06799ABC28F98E1D00ACD94E /* MullvadREST.framework */; }; 5823FA5426CE49F700283BF8 /* TunnelObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5823FA5326CE49F600283BF8 /* TunnelObserver.swift */; }; 5826B6CB2ABD83E200B1CA13 /* PacketTunnelOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587C575226D2615F005EF767 /* PacketTunnelOptions.swift */; }; @@ -223,7 +222,6 @@ 587B753F2668E5A700DEF7E9 /* NotificationContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587B753E2668E5A700DEF7E9 /* NotificationContainerView.swift */; }; 587B75412668FD7800DEF7E9 /* AccountExpirySystemNotificationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587B75402668FD7700DEF7E9 /* AccountExpirySystemNotificationProvider.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 */; }; @@ -244,7 +242,6 @@ 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 */; }; 5896CEF226972DEB00B0FAE8 /* AccountContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5896CEF126972DEB00B0FAE8 /* AccountContentView.swift */; }; @@ -1110,11 +1107,15 @@ F0FA16152D7F3E16007E2546 /* Collection+Sorting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF9BE8F2A39F26000DBFEDB /* Collection+Sorting.swift */; }; F0FADDEA2BE90AAA000D0B02 /* LaunchArguments.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0F1EF8C2BE8FF0A00CED01D /* LaunchArguments.swift */; }; F0FADDEC2BE90AB0000D0B02 /* LaunchArguments.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0F1EF8C2BE8FF0A00CED01D /* LaunchArguments.swift */; }; + F90A988A2E042D040020F64F /* ClearBackgroundView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F90A98892E042D040020F64F /* ClearBackgroundView.swift */; }; F910A4012D3FF23A002FF3BB /* View+Modifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = F910A4002D3FF22E002FF3BB /* View+Modifier.swift */; }; F910A4312D4A1B41002FF3BB /* InAppPurchaseCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F910A4302D4A1B3B002FF3BB /* InAppPurchaseCoordinator.swift */; }; F910A43A2D4A283D002FF3BB /* InAppPurchaseViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F910A4392D4A2839002FF3BB /* InAppPurchaseViewController.swift */; }; F910A8572D523812002FF3BB /* TunnelSettingsV7.swift in Sources */ = {isa = PBXBuildFile; fileRef = F910A8562D523812002FF3BB /* TunnelSettingsV7.swift */; }; F91B94A72DC9EB5E00132C28 /* MullvadInfoHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F91B94A62DC9EB5E00132C28 /* MullvadInfoHeaderView.swift */; }; + F91CCBF82DFABB75007F1925 /* DeviceManagementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F91CCBF72DFABB70007F1925 /* DeviceManagementView.swift */; }; + F91CCBFA2DFAC8ED007F1925 /* DeviceListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F91CCBF92DFAC8ED007F1925 /* DeviceListView.swift */; }; + F91CCBFC2DFAF5E6007F1925 /* MullvadProgressViewStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = F91CCBFB2DFAF5E1007F1925 /* MullvadProgressViewStyle.swift */; }; F924C4532D70692E001F4660 /* MullvadApiTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F924C4522D706929001F4660 /* MullvadApiTests.swift */; }; F924C5A42DA65F28001F4660 /* Storekit2.swift in Sources */ = {isa = PBXBuildFile; fileRef = F924C5A32DA65F28001F4660 /* Storekit2.swift */; }; F924C65F2DAE4554001F4660 /* ServerRelayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F924C65E2DAE4554001F4660 /* ServerRelayTests.swift */; }; @@ -1122,6 +1123,10 @@ F9394EEC2DBF56B6009595EA /* Color+Mullvad.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9394EEB2DBF56AA009595EA /* Color+Mullvad.swift */; }; F9394EF02DC0B58D009595EA /* MullvadListNavigationItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9394EEF2DC0B58D009595EA /* MullvadListNavigationItemView.swift */; }; F9394EF32DC21D8C009595EA /* MullvadList.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9394EF22DC21D8C009595EA /* MullvadList.swift */; }; + F97C38DF2DEEDB0F006DCB08 /* Color+Mullvad.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9394EEB2DBF56AA009595EA /* Color+Mullvad.swift */; }; + F97C38E32DEEDC28006DCB08 /* MullvadListActionItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F97C38E22DEEDC28006DCB08 /* MullvadListActionItemView.swift */; }; + F97C38E52DEEDFD6006DCB08 /* Image+Assets.swift in Sources */ = {isa = PBXBuildFile; fileRef = F97C38E42DEEDFD2006DCB08 /* Image+Assets.swift */; }; + F97C38E82DF025D9006DCB08 /* MullvadAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = F97C38E72DF025D9006DCB08 /* MullvadAlert.swift */; }; F97C38CA2DE49869006DCB08 /* MultihopSettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F97C38C92DE49869006DCB08 /* MultihopSettingsCoordinator.swift */; }; F97C38D92DE5930F006DCB08 /* CustomDNSCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F97C38D82DE59307006DCB08 /* CustomDNSCoordinator.swift */; }; F998EFF82D359C4600D88D01 /* SKProduct+Formatting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FD5BEF24238EB300112C88 /* SKProduct+Formatting.swift */; }; @@ -1713,8 +1718,7 @@ 581F23AE2A8CF94D00788AB6 /* PingerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PingerMock.swift; sourceTree = "<group>"; }; 5820675A26E6576800655B05 /* RelayCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayCache.swift; sourceTree = "<group>"; }; 5820676326E771DB00655B05 /* TunnelManagerErrors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelManagerErrors.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>"; }; + 5820EDA8288FE064006BF4E4 /* DeviceManaging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceManaging.swift; sourceTree = "<group>"; }; 58218E1428B65058000C624F /* IPv4Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = IPv4Header.h; sourceTree = "<group>"; }; 58218E1628B65396000C624F /* ICMPHeader.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ICMPHeader.h; sourceTree = "<group>"; }; 58225D252A84E8A10083D7F1 /* DefaultPathObserverProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultPathObserverProtocol.swift; sourceTree = "<group>"; }; @@ -1850,7 +1854,6 @@ 587B75402668FD7700DEF7E9 /* AccountExpirySystemNotificationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountExpirySystemNotificationProvider.swift; 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>"; }; @@ -1873,7 +1876,6 @@ 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>"; }; 5893C6FB29C311E9009090D1 /* ApplicationRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationRouter.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>"; }; @@ -2535,11 +2537,15 @@ F0FA160D2D7F2C3D007E2546 /* MockRelayCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockRelayCache.swift; sourceTree = "<group>"; }; F0FA160F2D7F2FC0007E2546 /* RelayFilterViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayFilterViewModelTests.swift; sourceTree = "<group>"; }; F0FBD98E2C4A60CC00EE5323 /* KeyExchangingResultStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyExchangingResultStub.swift; sourceTree = "<group>"; }; + F90A98892E042D040020F64F /* ClearBackgroundView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClearBackgroundView.swift; sourceTree = "<group>"; }; F910A4002D3FF22E002FF3BB /* View+Modifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Modifier.swift"; sourceTree = "<group>"; }; F910A4302D4A1B3B002FF3BB /* InAppPurchaseCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppPurchaseCoordinator.swift; sourceTree = "<group>"; }; F910A4392D4A2839002FF3BB /* InAppPurchaseViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppPurchaseViewController.swift; sourceTree = "<group>"; }; F910A8562D523812002FF3BB /* TunnelSettingsV7.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelSettingsV7.swift; sourceTree = "<group>"; }; F91B94A62DC9EB5E00132C28 /* MullvadInfoHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MullvadInfoHeaderView.swift; sourceTree = "<group>"; }; + F91CCBF72DFABB70007F1925 /* DeviceManagementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceManagementView.swift; sourceTree = "<group>"; }; + F91CCBF92DFAC8ED007F1925 /* DeviceListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceListView.swift; sourceTree = "<group>"; }; + F91CCBFB2DFAF5E1007F1925 /* MullvadProgressViewStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MullvadProgressViewStyle.swift; sourceTree = "<group>"; }; F924C4522D706929001F4660 /* MullvadApiTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MullvadApiTests.swift; sourceTree = "<group>"; }; F924C5A32DA65F28001F4660 /* Storekit2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storekit2.swift; sourceTree = "<group>"; }; F924C65E2DAE4554001F4660 /* ServerRelayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerRelayTests.swift; sourceTree = "<group>"; }; @@ -2547,6 +2553,9 @@ F9394EEB2DBF56AA009595EA /* Color+Mullvad.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Mullvad.swift"; sourceTree = "<group>"; }; F9394EEF2DC0B58D009595EA /* MullvadListNavigationItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MullvadListNavigationItemView.swift; sourceTree = "<group>"; }; F9394EF22DC21D8C009595EA /* MullvadList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MullvadList.swift; sourceTree = "<group>"; }; + F97C38E22DEEDC28006DCB08 /* MullvadListActionItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MullvadListActionItemView.swift; sourceTree = "<group>"; }; + F97C38E42DEEDFD2006DCB08 /* Image+Assets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Image+Assets.swift"; sourceTree = "<group>"; }; + F97C38E72DF025D9006DCB08 /* MullvadAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MullvadAlert.swift; sourceTree = "<group>"; }; F97C38C92DE49869006DCB08 /* MultihopSettingsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultihopSettingsCoordinator.swift; sourceTree = "<group>"; }; F97C38D82DE59307006DCB08 /* CustomDNSCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDNSCoordinator.swift; sourceTree = "<group>"; }; F998EFF92D3656B100D88D01 /* SKProduct+Sorting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SKProduct+Sorting.swift"; sourceTree = "<group>"; }; @@ -3302,10 +3311,9 @@ 583FE01D29C197C1006E85F9 /* DeviceList */ = { isa = PBXGroup; children = ( - 587D96732886D87C00CD8F1C /* DeviceManagementContentView.swift */, - 5820EDA8288FE064006BF4E4 /* DeviceManagementInteractor.swift */, - 5893716928817A45004EE76C /* DeviceManagementViewController.swift */, - 5820EDAA288FF0D2006BF4E4 /* DeviceRowView.swift */, + F91CCBF72DFABB70007F1925 /* DeviceManagementView.swift */, + F91CCBF92DFAC8ED007F1925 /* DeviceListView.swift */, + 5820EDA8288FE064006BF4E4 /* DeviceManaging.swift */, ); path = DeviceList; sourceTree = "<group>"; @@ -3326,9 +3334,12 @@ 583FE01F29C197ED006E85F9 /* Views */ = { isa = PBXGroup; children = ( + F91CCBFB2DFAF5E1007F1925 /* MullvadProgressViewStyle.swift */, + F97C38E72DF025D9006DCB08 /* MullvadAlert.swift */, 7A5869962B32EA4500640D27 /* AppButton.swift */, 7A0EAEA12D033D5A00D3EB8B /* BlurView.swift */, 7A9FA1412A2E3306000B728D /* CheckboxView.swift */, + F90A98892E042D040020F64F /* ClearBackgroundView.swift */, 5868585424054096000B8131 /* CustomButton.swift */, 58ACF64C26567A4F00ACE4B7 /* CustomSwitch.swift */, 58ACF64E26567A7100ACE4B7 /* CustomSwitchContainer.swift */, @@ -3400,6 +3411,7 @@ 7AF9BE8F2A39F26000DBFEDB /* Collection+Sorting.swift */, 7A0EAE9F2D0333CB00D3EB8B /* Color+Helpers.swift */, 7A0C0F622A979C4A0058EFCE /* Coordinator+Router.swift */, + F97C38E42DEEDFD2006DCB08 /* Image+Assets.swift */, 5811DE4F239014550011EB53 /* NEVPNStatus+Debug.swift */, 58DFF7CF2B02560400F864E0 /* NSAttributedString+Extensions.swift */, 587D9675288989DB00CD8F1C /* NSLayoutConstraint+Helpers.swift */, @@ -4917,6 +4929,7 @@ F9394EF12DC21D7B009595EA /* List */ = { isa = PBXGroup; children = ( + F97C38E22DEEDC28006DCB08 /* MullvadListActionItemView.swift */, F9394EEF2DC0B58D009595EA /* MullvadListNavigationItemView.swift */, F9394EF22DC21D8C009595EA /* MullvadList.swift */, ); @@ -5952,7 +5965,7 @@ 7A5869C32B5820CE00640D27 /* IPOverrideRepositoryTests.swift in Sources */, A9A5FA392ACB05910083449F /* UIColor+Palette.swift in Sources */, 7A5468AD2C6B5E4B00590086 /* LocationRelays.swift in Sources */, - A9EE855F2DF0893E00F2D769 /* Color+Mullvad.swift in Sources */, + F97C38DF2DEEDB0F006DCB08 /* Color+Mullvad.swift in Sources */, A9A5FA3A2ACB05910083449F /* UIEdgeInsets+Extensions.swift in Sources */, A9A5FA3B2ACB05910083449F /* UIMetrics.swift in Sources */, 58B07C182AEFDD6C00A09625 /* StoreTransactionLog.swift in Sources */, @@ -6294,13 +6307,14 @@ 7AB2B6702BA1EB8C00B03E3B /* ListCustomListViewController.swift in Sources */, 7A9CCCB72A96302800DD6A34 /* RevokedCoordinator.swift in Sources */, 7A6389F82B864CDF008E77E1 /* LocationNode.swift in Sources */, - 587D96742886D87C00CD8F1C /* DeviceManagementContentView.swift in Sources */, 7A8A19142CEF2548000BCB5B /* DAITATunnelSettingsViewModel.swift in Sources */, + F97C38E32DEEDC28006DCB08 /* MullvadListActionItemView.swift in Sources */, 7A8A18F92CE34EA8000BCB5B /* SettingsMultihopView.swift in Sources */, 44BB5F972BE527F4002520EB /* TunnelState+UI.swift in Sources */, 7AFBE3892D089163002335FC /* TunnelViewController.swift in Sources */, 7A11DD0B2A9495D400098CD8 /* AppRoutes.swift in Sources */, 5827B0902B0CAA0500CCBBA1 /* EditAccessMethodCoordinator.swift in Sources */, + F97C38E52DEEDFD6006DCB08 /* Image+Assets.swift in Sources */, 5846227126E229F20035F7C2 /* StoreSubscription.swift in Sources */, 58421030282D8A3C00F24E46 /* UpdateAccountDataOperation.swift in Sources */, 7A8A190A2CE5FFE9000BCB5B /* SettingsDAITAView.swift in Sources */, @@ -6324,6 +6338,7 @@ 5827B0922B0CAB2800CCBBA1 /* MethodSettingsViewController.swift in Sources */, F01DAE332C2B032A00521E46 /* RelaySelection.swift in Sources */, 58B993B12608A34500BA7811 /* LoginContentView.swift in Sources */, + F91CCBFC2DFAF5E6007F1925 /* MullvadProgressViewStyle.swift in Sources */, 7A516C2E2B6D357500BBD33D /* URL+Scoping.swift in Sources */, 7AA636382D2D3BB0009B2C89 /* View+Conditionals.swift in Sources */, 5878A27529093A310096FC88 /* StorePaymentEvent.swift in Sources */, @@ -6332,6 +6347,7 @@ F910A4012D3FF23A002FF3BB /* View+Modifier.swift in Sources */, 7A6389EB2B7FAD7A008E77E1 /* SettingsFieldValidationErrorContentView.swift in Sources */, 440870822D7A00B70038972F /* UIImage+Assets.swift in Sources */, + F91CCBF82DFABB75007F1925 /* DeviceManagementView.swift in Sources */, 7A8A19282CF603EB000BCB5B /* SettingsViewControllerFactory.swift in Sources */, 58B26E2A2943545A00D5980C /* NotificationManagerDelegate.swift in Sources */, 7A8A19072CE4E9D3000BCB5B /* SettingsInfoView.swift in Sources */, @@ -6357,7 +6373,6 @@ F0B495782D02038B00CFEC2A /* ChipViewModelProtocol.swift in Sources */, 58CEB30A2AFD584700E6E088 /* CustomCellDisclosureHandling.swift in Sources */, 58B26E22294351EA00D5980C /* InAppNotificationProvider.swift in Sources */, - 5893716A28817A45004EE76C /* DeviceManagementViewController.swift in Sources */, 7A9CCCB82A96302800DD6A34 /* SetupAccountCompletedCoordinator.swift in Sources */, F09C97232D3122F300ADE747 /* ChangeLogReader.swift in Sources */, 447F3D8B2CDE1853006E3462 /* ShadowsocksObfuscationSettingsView.swift in Sources */, @@ -6385,7 +6400,7 @@ F050AE4E2B70D7F8003F4EDB /* LocationCellViewModel.swift in Sources */, 58CEB30C2AFD586600E6E088 /* DynamicBackgroundConfiguration.swift in Sources */, 587B7536266528A200DEF7E9 /* NotificationManager.swift in Sources */, - 5820EDA9288FE064006BF4E4 /* DeviceManagementInteractor.swift in Sources */, + 5820EDA9288FE064006BF4E4 /* DeviceManaging.swift in Sources */, F03A69F92C2AD414000E2E7E /* FormsheetPresentationController.swift in Sources */, 7A9CCCB92A96302800DD6A34 /* LocationCoordinator.swift in Sources */, 58FB865A26EA214400F188BC /* RelayCacheTrackerObserver.swift in Sources */, @@ -6416,12 +6431,12 @@ 5878A279290954790096FC88 /* TunnelViewControllerInteractor.swift in Sources */, A9A60ED42DF6E5AC00CD9C3D /* UIHostingRootController.swift in Sources */, 7A818F1F29F0305800C7F0F4 /* RootConfiguration.swift in Sources */, + F91CCBFA2DFAC8ED007F1925 /* DeviceListView.swift in Sources */, 7A9CCCBF2A96302800DD6A34 /* SettingsCoordinator.swift in Sources */, 58F70FE52AEA707800E6890E /* StoreTransactionLog.swift in Sources */, F9394EF02DC0B58D009595EA /* MullvadListNavigationItemView.swift in Sources */, 582AE3102440A6CA00E6733A /* InputTextFormatter.swift in Sources */, 7A6F2FAD2AFD3DA7006D0856 /* CustomDNSViewController.swift in Sources */, - 5820EDAB288FF0D2006BF4E4 /* DeviceRowView.swift in Sources */, F0E8CC0C2A4EE672007ED3B4 /* SetupAccountCompletedController.swift in Sources */, 5846227726E22A7C0035F7C2 /* StorePaymentManagerDelegate.swift in Sources */, 58EF581125D69DB400AEBA94 /* StatusImageView.swift in Sources */, @@ -6437,6 +6452,7 @@ 7A0EAE9A2D01B41500D3EB8B /* MainButtonStyle.swift in Sources */, 58CEB3022AFD365600E6E088 /* SwitchCellContentConfiguration.swift in Sources */, 7AA130A12D01B1E200640DF9 /* SplitMainButton.swift in Sources */, + F97C38E82DF025D9006DCB08 /* MullvadAlert.swift in Sources */, 7AA1309F2D007B2500640DF9 /* VisualEffectView.swift in Sources */, 7A0C0F632A979C4A0058EFCE /* Coordinator+Router.swift in Sources */, 7A6F2FAB2AFD3097006D0856 /* CustomDNSCellFactory.swift in Sources */, @@ -6677,6 +6693,7 @@ 586A950F29012BEE007BAF2B /* AddressCacheTracker.swift in Sources */, 7A7907332BC0280A00B61F81 /* InterceptibleNavigationController.swift in Sources */, F09C97252D312ED000ADE747 /* BulletPointText.swift in Sources */, + F90A988A2E042D040020F64F /* ClearBackgroundView.swift in Sources */, F02F41A02B9723AF00625A4F /* AddLocationsViewController.swift in Sources */, 587B753D2666468F00DEF7E9 /* NotificationController.swift in Sources */, ); diff --git a/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift b/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift index 0dfb9dbdda..7d0196560c 100644 --- a/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift +++ b/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift @@ -33,6 +33,7 @@ public enum AccessibilityIdentifier: Equatable { case revokedDeviceLoginButton case dnsSettingsEditButton case infoButton + case deviceManagementButton case copyButton case learnAboutPrivacyButton case logOutDeviceConfirmButton @@ -184,6 +185,8 @@ public enum AccessibilityIdentifier: Equatable { case deleteAccountTextField case socks5AuthenticationSwitch case statusImageView + case deviceListView + case deviceRemovalProgressView // DNS settings case includeAllNetworks diff --git a/ios/MullvadVPN/Coordinators/AccountCoordinator.swift b/ios/MullvadVPN/Coordinators/AccountCoordinator.swift index 582115dfb4..4d83a4716f 100644 --- a/ios/MullvadVPN/Coordinators/AccountCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/AccountCoordinator.swift @@ -6,8 +6,10 @@ // Copyright © 2025 Mullvad VPN AB. All rights reserved. // +import MullvadREST import Routing import StoreKit +import SwiftUI import UIKit enum AccountDismissReason: Equatable, Sendable { @@ -54,8 +56,8 @@ final class AccountCoordinator: Coordinator, Presentable, Presenting, @unchecked private func handleViewControllerAction(_ action: AccountViewControllerAction) { switch action { - case .deviceInfo: - showAccountDeviceInfo() + case .deviceManagement: + navigateToDeviceManagement() case .finish: didFinish?(self, .none) case .logOut: @@ -122,6 +124,72 @@ final class AccountCoordinator: Coordinator, Presentable, Presenting, @unchecked ) } + private func navigateToDeviceManagement() { + guard let accountNumber = interactor.deviceState.accountData?.number, + let currentDeviceId = interactor.deviceState.deviceData?.identifier else { + return + } + let controller = UIHostingController( + rootView: DeviceManagementView( + deviceManaging: DeviceManagementInteractor( + accountNumber: accountNumber, + currentDeviceId: currentDeviceId, + devicesProxy: interactor.deviceProxy + ), + style: .normal, + onError: { title, error in + let errorDescription = if case let .network(urlError) = error as? REST.Error { + urlError.localizedDescription + } else { + error.localizedDescription + } + let presentation = AlertPresentation( + id: "device-management-error-alert", + title: title, + message: errorDescription, + buttons: [ + AlertAction( + title: NSLocalizedString( + "ERROR_ALERT_OK_ACTION", + tableName: "DeviceManagement", + value: "Got it!", + comment: "" + ), + style: .default + ), + ] + ) + + let presenter = AlertPresenter(context: self) + presenter.showAlert(presentation: presentation, animated: true) + } + ) + ) + controller.title = NSLocalizedString( + "MANAGE_DEVICES_TITLE", + tableName: "Manage devices", + value: "Manage devices", + comment: "" + ) + let doneButton = UIBarButtonItem( + systemItem: .done, + primaryAction: UIAction(handler: { _ in + controller.dismiss(animated: true) + }) + ) + controller.navigationItem.rightBarButtonItem = doneButton + let subNavigationController = CustomNavigationController( + rootViewController: controller + ) + subNavigationController.navigationItem.largeTitleDisplayMode = .always + subNavigationController.navigationBar.prefersLargeTitles = true + navigationController + .present( + subNavigationController, + animated: true + ) + } + @MainActor private func navigateToDeleteAccount() { let coordinator = AccountDeletionCoordinator( @@ -182,39 +250,6 @@ final class AccountCoordinator: Coordinator, Presentable, Presenting, @unchecked alertPresenter.showAlert(presentation: presentation, animated: true) } - private func showAccountDeviceInfo() { - let message = NSLocalizedString( - "DEVICE_INFO_DIALOG_MESSAGE_PART_1", - tableName: "Account", - value: """ - This is the name assigned to the device. Each device logged in on a Mullvad account gets a unique name \ - that helps you identify it when you manage your devices in the app or on the website. - You can have up to 5 devices logged in on one Mullvad account. - If you log out, the device and the device name is removed. When \ - you log back in again, the device will get a new name. - """, - comment: "" - ) - - let presentation = AlertPresentation( - id: "account-device-info-alert", - icon: .info, - message: message, - buttons: [AlertAction( - title: NSLocalizedString( - "DEVICE_INFO_DIALOG_OK_ACTION", - tableName: "Account", - value: "Got it!", - comment: "" - ), - style: .default - )] - ) - - let presenter = AlertPresenter(context: self) - presenter.showAlert(presentation: presentation, animated: true) - } - private func showRestorePurchasesInfo() { let message = NSLocalizedString( "RESTORE_PURCHASES_DIALOG_MESSAGE", diff --git a/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift b/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift index bd67bbb072..952457a926 100644 --- a/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift @@ -546,7 +546,8 @@ final class ApplicationCoordinator: Coordinator, Presenting, @preconcurrency Roo let accountInteractor = AccountInteractor( tunnelManager: tunnelManager, accountsProxy: accountsProxy, - apiProxy: apiProxy + apiProxy: apiProxy, + deviceProxy: devicesProxy ) let coordinator = AccountCoordinator( diff --git a/ios/MullvadVPN/Coordinators/LoginCoordinator.swift b/ios/MullvadVPN/Coordinators/LoginCoordinator.swift index b6f69f95d8..46220d0158 100644 --- a/ios/MullvadVPN/Coordinators/LoginCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/LoginCoordinator.swift @@ -11,9 +11,10 @@ import MullvadREST import MullvadTypes import Operations import Routing +import SwiftUI import UIKit -final class LoginCoordinator: Coordinator, Presenting, @preconcurrency DeviceManagementViewControllerDelegate { +final class LoginCoordinator: Coordinator, Presenting { private let tunnelManager: TunnelManager private let devicesProxy: DeviceHandling @@ -63,16 +64,6 @@ final class LoginCoordinator: Coordinator, Presenting, @preconcurrency DeviceMan self.loginController = loginController } - // MARK: - DeviceManagementViewControllerDelegate - - func deviceManagementViewControllerDidCancel(_ controller: DeviceManagementViewController) { - returnToLogin(repeatLogin: false) - } - - func deviceManagementViewControllerDidFinish(_ controller: DeviceManagementViewController) { - returnToLogin(repeatLogin: true) - } - // MARK: - Private private func didFinishLogin(action: LoginAction, error: Error?) -> EndLoginAction { @@ -119,27 +110,51 @@ final class LoginCoordinator: Coordinator, Presenting, @preconcurrency DeviceMan accountNumber: accountNumber, devicesProxy: devicesProxy ) - let controller = DeviceManagementViewController( - interactor: interactor, - alertPresenter: AlertPresenter(context: self) - ) - controller.isModalInPresentation = true - controller.delegate = self - - controller.fetchDevices(animateUpdates: false) { [weak self] result in - guard let self = self else { return } - - switch result { - case .success: - Task { @MainActor in - navigationController.present(controller, animated: true) { - completion(nil) + let controller = UIHostingController( + rootView: DeviceManagementView( + deviceManaging: interactor, + style: .tooManyDevices(returnToLogin), + onError: { title, error in + let errorDescription = if case let .network(urlError) = error as? REST.Error { + urlError.localizedDescription + } else { + error.localizedDescription } - } + let presentation = AlertPresentation( + id: "delete-device-error-alert", + title: title, + message: errorDescription, + buttons: [ + AlertAction( + title: NSLocalizedString( + "ERROR_ALERT_OK_ACTION", + tableName: "DeviceManagement", + value: "Got it!", + comment: "" + ), + style: .default + ), + ] + ) - case let .failure(error): - completion(error) + let presenter = AlertPresenter(context: self) + presenter.showAlert(presentation: presentation, animated: true) + } + ) + ) + controller.navigationItem.rightBarButtonItem = UIBarButtonItem( + systemItem: .cancel, + primaryAction: UIAction(handler: { _ in + controller.dismiss(animated: true) + }) + ) + controller.isModalInPresentation = true + navigationController + .present( + CustomNavigationController(rootViewController: controller), + animated: true + ) { + completion(nil) } - } } } diff --git a/ios/MullvadVPN/Extensions/Image+Assets.swift b/ios/MullvadVPN/Extensions/Image+Assets.swift new file mode 100644 index 0000000000..0f61db0ed4 --- /dev/null +++ b/ios/MullvadVPN/Extensions/Image+Assets.swift @@ -0,0 +1,13 @@ +import SwiftUI + +extension Image { + static var mullvadIconClose: some View { Image("IconClose") + .resizable() + .frame(width: 25, height: 25) + } + + static let mullvadIconAlert = Image("IconAlert") + static let mullvadIconSpinner = Image("IconSpinner") + static let mullvadIconSuccess = Image("IconSuccess") + static let mullvadIconFail = Image("IconFail") +} diff --git a/ios/MullvadVPN/Extensions/View+Modifier.swift b/ios/MullvadVPN/Extensions/View+Modifier.swift index 70a40a2ed1..7cb201f01f 100644 --- a/ios/MullvadVPN/Extensions/View+Modifier.swift +++ b/ios/MullvadVPN/Extensions/View+Modifier.swift @@ -23,4 +23,19 @@ extension View { ``` */ func apply<V: View>(@ViewBuilder _ block: (Self) -> V) -> V { block(self) } + + /** + Uses the AccessibilityIdentifier you specify to identify the view. + # Discussion # + Use this value for testing. It isn’t visible to the user. + */ + func accessibilityIdentifier(_ id: AccessibilityIdentifier?) -> some View { + apply { + if let id { + $0.accessibilityIdentifier(id.asString) + } else { + $0 + } + } + } } diff --git a/ios/MullvadVPN/UI appearance/Color+Mullvad.swift b/ios/MullvadVPN/UI appearance/Color+Mullvad.swift index ab3987c722..f4c85dd81f 100644 --- a/ios/MullvadVPN/UI appearance/Color+Mullvad.swift +++ b/ios/MullvadVPN/UI appearance/Color+Mullvad.swift @@ -28,5 +28,6 @@ extension Color { enum MullvadList { static let separator: Color = .mullvadSecondaryColor + static let background: Color = .mullvadPrimaryColor } } diff --git a/ios/MullvadVPN/View controllers/Account/AccountDeviceRow.swift b/ios/MullvadVPN/View controllers/Account/AccountDeviceRow.swift index cb2524c427..54b9188a1d 100644 --- a/ios/MullvadVPN/View controllers/Account/AccountDeviceRow.swift +++ b/ios/MullvadVPN/View controllers/Account/AccountDeviceRow.swift @@ -17,7 +17,7 @@ class AccountDeviceRow: UIView { } } - var infoButtonAction: (() -> Void)? + var deviceManagementButtonAction: (() -> Void)? private let titleLabel: UILabel = { let label = UILabel() @@ -39,14 +39,26 @@ class AccountDeviceRow: UIView { return label }() - private let infoButton: UIButton = { + private let deviceManagementButton: UIButton = { let button = IncreasedHitButton(type: .system) button.isExclusiveTouch = true - button.setAccessibilityIdentifier(.infoButton) - button.tintColor = .white - button.setBackgroundImage(UIImage.Buttons.info, for: .normal) - button.heightAnchor.constraint(equalToConstant: UIMetrics.Button.accountInfoSize).isActive = true - button.widthAnchor.constraint(equalTo: button.heightAnchor, multiplier: 1).isActive = true + button.setAccessibilityIdentifier(.deviceManagementButton) + let attributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.mullvadSmallSemiBold, + .foregroundColor: UIColor.primaryTextColor, + .underlineStyle: NSUnderlineStyle.single.rawValue, + ] + let title = NSLocalizedString( + "DEVICE_MANAGEMENT", + tableName: "Account", + value: "Manage devices", + comment: "" + ) + let attributeString = NSMutableAttributedString( + string: title, + attributes: attributes + ) + button.setAttributedTitle(attributeString, for: .normal) return button }() @@ -58,18 +70,20 @@ class AccountDeviceRow: UIView { contentContainerView.alignment = .leading contentContainerView.spacing = 8 - addConstrainedSubviews([contentContainerView, infoButton]) { + addConstrainedSubviews( + [contentContainerView, deviceManagementButton] + ) { contentContainerView.pinEdgesToSuperview() - infoButton.leadingAnchor.constraint(equalToSystemSpacingAfter: deviceLabel.trailingAnchor, multiplier: 1) - infoButton.centerYAnchor.constraint(equalTo: deviceLabel.centerYAnchor) + deviceManagementButton.centerYAnchor.constraint(equalTo: deviceLabel.centerYAnchor) + deviceManagementButton.pinEdgeToSuperview(.trailing(0)) } isAccessibilityElement = true accessibilityLabel = titleLabel.text - infoButton.addTarget( + deviceManagementButton.addTarget( self, - action: #selector(didTapInfoButton), + action: #selector(didTapDeviceManagementButton), for: .touchUpInside ) } @@ -79,10 +93,10 @@ class AccountDeviceRow: UIView { } func setButtons(enabled: Bool) { - infoButton.isEnabled = enabled + deviceManagementButton.isEnabled = enabled } - @objc private func didTapInfoButton() { - infoButtonAction?() + @objc private func didTapDeviceManagementButton() { + deviceManagementButtonAction?() } } diff --git a/ios/MullvadVPN/View controllers/Account/AccountInteractor.swift b/ios/MullvadVPN/View controllers/Account/AccountInteractor.swift index c11899906a..043c734fd8 100644 --- a/ios/MullvadVPN/View controllers/Account/AccountInteractor.swift +++ b/ios/MullvadVPN/View controllers/Account/AccountInteractor.swift @@ -17,6 +17,7 @@ final class AccountInteractor: Sendable { let tunnelManager: TunnelManager let accountsProxy: RESTAccountHandling let apiProxy: APIQuerying + let deviceProxy: DeviceHandling nonisolated(unsafe) var didReceiveDeviceState: (@Sendable (DeviceState) -> Void)? @@ -25,11 +26,13 @@ final class AccountInteractor: Sendable { init( tunnelManager: TunnelManager, accountsProxy: RESTAccountHandling, - apiProxy: APIQuerying + apiProxy: APIQuerying, + deviceProxy: DeviceHandling ) { self.tunnelManager = tunnelManager self.accountsProxy = accountsProxy self.apiProxy = apiProxy + self.deviceProxy = deviceProxy let tunnelObserver = TunnelBlockObserver(didUpdateDeviceState: { [weak self] _, deviceState, _ in diff --git a/ios/MullvadVPN/View controllers/Account/AccountViewController.swift b/ios/MullvadVPN/View controllers/Account/AccountViewController.swift index eb3d2acd3b..4c347b573c 100644 --- a/ios/MullvadVPN/View controllers/Account/AccountViewController.swift +++ b/ios/MullvadVPN/View controllers/Account/AccountViewController.swift @@ -15,7 +15,7 @@ import StoreKit import UIKit enum AccountViewControllerAction: Sendable { - case deviceInfo + case deviceManagement case finish case logOut case navigateToVoucher @@ -82,8 +82,8 @@ class AccountViewController: UIViewController, @unchecked Sendable { self?.copyAccountToken() } - contentView.accountDeviceRow.infoButtonAction = { [weak self] in - self?.actionHandler?(.deviceInfo) + contentView.accountDeviceRow.deviceManagementButtonAction = { [weak self] in + self?.actionHandler?(.deviceManagement) } contentView.restorePurchasesView.restoreButtonAction = { [weak self] in diff --git a/ios/MullvadVPN/View controllers/DeviceList/DeviceListView.swift b/ios/MullvadVPN/View controllers/DeviceList/DeviceListView.swift new file mode 100644 index 0000000000..bfcdb83d70 --- /dev/null +++ b/ios/MullvadVPN/View controllers/DeviceList/DeviceListView.swift @@ -0,0 +1,92 @@ +import SwiftUI + +struct DeviceListView: View { + @Binding var devices: [Device] + @Binding var loading: Bool + var onRemoveDevice: ((Device) -> Void)? + + struct Device: Identifiable, Hashable { + let id: String + let name: String + let created: Date + let isCurrentDevice: Bool + var isBeingRemoved: Bool + + func setIsBeingRemoved(_ isBeingRemoved: Bool) -> Self { + var updatedSelf = self + updatedSelf.isBeingRemoved = isBeingRemoved + return updatedSelf + } + } + + var body: some View { + if loading { + ProgressView() + .progressViewStyle(MullvadProgressViewStyle()) + .padding(.top, 24) + Text("Fetching devices...") + .padding(.top, 16) + .foregroundColor(.mullvadTextPrimary.opacity(0.6)) + } else { + MullvadList(devices) { device in + MullvadListActionItemView( + item: .init( + id: device.id, + title: LocalizedStringKey(device.name), + state: device.isCurrentDevice ? "Current device" : nil, + detail: "Created: \(device.created.formatted(date: .long, time: .omitted))", + accessibilityIdentifier: AccessibilityIdentifier.deviceCellRemoveButton, + pressed: { + onRemoveDevice?(device) + } + ), + icon: { + if !device.isCurrentDevice { + if device.isBeingRemoved { + ProgressView() + .progressViewStyle(MullvadProgressViewStyle()) + .frame(width: 24, height: 24) + .accessibilityIdentifier(.deviceRemovalProgressView) + } else { + Image.mullvadIconClose + } + } + } + ) + } + .accessibilityIdentifier(.deviceListView) + } + } +} + +#Preview { + DeviceListView( + devices: .constant([ + DeviceListView.Device( + id: "1", + name: "Test device", + created: Date(), + isCurrentDevice: false, + isBeingRemoved: true + ), + DeviceListView.Device( + id: "2", + name: "Test device", + created: Date(), + isCurrentDevice: false, + isBeingRemoved: false + ), + ]), + loading: .constant(false), + onRemoveDevice: nil + ) +} + +#Preview("Loading") { + DeviceListView( + devices: .constant([]), + loading: .constant(true), + onRemoveDevice: nil + ) + .background(Color.mullvadBackground) +} diff --git a/ios/MullvadVPN/View controllers/DeviceList/DeviceManagementContentView.swift b/ios/MullvadVPN/View controllers/DeviceList/DeviceManagementContentView.swift deleted file mode 100644 index 9169be90b4..0000000000 --- a/ios/MullvadVPN/View controllers/DeviceList/DeviceManagementContentView.swift +++ /dev/null @@ -1,319 +0,0 @@ -// -// DeviceManagementContentView.swift -// MullvadVPN -// -// Created by pronebird on 19/07/2022. -// Copyright © 2025 Mullvad VPN AB. All rights reserved. -// - -import UIKit - -class DeviceManagementContentView: UIView { - private let scrollView: UIScrollView = { - let scrollView = UIScrollView() - scrollView.translatesAutoresizingMaskIntoConstraints = false - return scrollView - }() - - let scrollContentView: UIView = { - let view = UIView() - view.directionalLayoutMargins = UIMetrics.contentLayoutMargins - view.translatesAutoresizingMaskIntoConstraints = false - return view - }() - - 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 - textLabel.lineBreakStrategy = [] - return textLabel - }() - - let deviceStackView: UIStackView = { - let stackView = UIStackView(arrangedSubviews: []) - stackView.translatesAutoresizingMaskIntoConstraints = false - stackView.axis = .vertical - stackView.spacing = 1 - stackView.clipsToBounds = true - stackView.distribution = .fillEqually - return stackView - }() - - 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 - ) - button.isEnabled = false - button.setAccessibilityIdentifier(.continueWithLoginButton) - return button - }() - - let cancelButton: AppButton = { - let button = AppButton(style: .default) - button.translatesAutoresizingMaskIntoConstraints = false - button.setTitle( - NSLocalizedString( - "CANCEL_BUTTON", - tableName: "DeviceManagement", - value: "Cancel", - comment: "" - ), - for: .normal - ) - return button - }() - - private lazy var buttonStackView: UIStackView = { - let stackView = UIStackView(arrangedSubviews: [continueButton, cancelButton]) - stackView.translatesAutoresizingMaskIntoConstraints = false - stackView.axis = .vertical - stackView.distribution = .fillEqually - stackView.spacing = UIMetrics.interButtonSpacing - return stackView - }() - - var handleDeviceDeletion: (@Sendable (DeviceViewModel, @escaping @Sendable () -> Void) -> Void)? - - private var currentDeviceModels = [DeviceViewModel]() - - var canContinue = false { - didSet { - updateView() - } - } - - override init(frame: CGRect) { - super.init(frame: frame) - - addViews() - constraintViews() - updateView() - - setAccessibilityIdentifier(.deviceManagementView) - } - - private func addViews() { - try? [scrollView, buttonStackView].forEach(addSubview) - - scrollView.addSubview(scrollContentView) - - try? [statusImageView, titleLabel, messageLabel, deviceStackView] - .forEach(scrollContentView.addSubview) - } - - private func constraintViews() { - NSLayoutConstraint.activate([ - scrollView.topAnchor.constraint(equalTo: topAnchor, constant: 16), - scrollView.leadingAnchor.constraint(equalTo: leadingAnchor), - scrollView.trailingAnchor.constraint(equalTo: trailingAnchor), - - buttonStackView.topAnchor.constraint( - equalTo: scrollView.bottomAnchor, - constant: UIMetrics.contentLayoutMargins.top - ), - buttonStackView.leadingAnchor.constraint( - equalTo: leadingAnchor, - constant: UIMetrics.contentLayoutMargins.leading - ), - buttonStackView.trailingAnchor.constraint( - equalTo: trailingAnchor, - constant: -UIMetrics.contentLayoutMargins.trailing - ), - buttonStackView.bottomAnchor.constraint( - equalTo: safeAreaLayoutGuide.bottomAnchor, - constant: -UIMetrics.contentLayoutMargins.bottom - ), - - scrollContentView.topAnchor.constraint(equalTo: scrollView.topAnchor), - scrollContentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor), - scrollContentView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor), - scrollContentView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor), - scrollContentView.widthAnchor.constraint(equalTo: scrollView.widthAnchor), - - statusImageView.topAnchor - .constraint(equalTo: scrollContentView.topAnchor), - statusImageView.centerXAnchor.constraint(equalTo: scrollContentView.centerXAnchor), - - titleLabel.topAnchor.constraint(equalTo: statusImageView.bottomAnchor, constant: 22), - titleLabel.leadingAnchor - .constraint(equalTo: scrollContentView.layoutMarginsGuide.leadingAnchor), - titleLabel.trailingAnchor - .constraint(equalTo: scrollContentView.layoutMarginsGuide.trailingAnchor), - - messageLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8), - messageLabel.leadingAnchor - .constraint(equalTo: scrollContentView.layoutMarginsGuide.leadingAnchor), - messageLabel.trailingAnchor - .constraint(equalTo: scrollContentView.layoutMarginsGuide.trailingAnchor), - - deviceStackView.topAnchor.constraint( - equalTo: messageLabel.bottomAnchor, - constant: UIMetrics.TableView.sectionSpacing - ), - deviceStackView.leadingAnchor.constraint(equalTo: scrollContentView.leadingAnchor), - deviceStackView.trailingAnchor.constraint(equalTo: scrollContentView.trailingAnchor), - deviceStackView.bottomAnchor.constraint(equalTo: scrollContentView.bottomAnchor), - ]) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func setDeviceViewModels(_ newModels: [DeviceViewModel], animated: Bool) { - let difference = newModels.difference(from: currentDeviceModels) { newModel, model in - newModel.id == model.id - } - - currentDeviceModels = newModels - - var viewsToAdd: [(view: UIView, offset: Int)] = [] - var viewsToRemove: [UIView] = [] - - difference.forEach { change in - switch change { - case let .insert(offset, model, _): - viewsToAdd.append((createDeviceRowView(from: model), offset)) - case let .remove(offset, _, _): - viewsToRemove.append(deviceStackView.arrangedSubviews[offset]) - } - } - - viewsToAdd.forEach { item in - deviceStackView.insertArrangedSubview(item.view, at: item.offset) - } - - // Layout inserted subviews before running animations to achieve a folding effect. - if animated { - UIView.performWithoutAnimation { - deviceStackView.layoutIfNeeded() - } - } - - if animated { - UIView.animate( - withDuration: 0.25, - delay: 0, - options: [.curveEaseInOut], - animations: { [weak self] in - self?.showHideViews(viewsToAdd: viewsToAdd, viewsToRemove: viewsToRemove) - self?.deviceStackView.layoutIfNeeded() - }, - completion: { [weak self] _ in - self?.removeViews(viewsToRemove: viewsToRemove) - } - ) - } else { - showHideViews(viewsToAdd: viewsToAdd, viewsToRemove: viewsToRemove) - removeViews(viewsToRemove: viewsToRemove) - } - } - - private func showHideViews(viewsToAdd: [(view: UIView, offset: Int)], viewsToRemove: [UIView]) { - viewsToRemove.forEach { view in - view.alpha = 0 - view.isHidden = true - } - - viewsToAdd.forEach { item in - item.view.alpha = 1 - item.view.isHidden = false - } - } - - private func removeViews(viewsToRemove: [UIView]) { - viewsToRemove.forEach { view in - view.removeFromSuperview() - } - } - - private func createDeviceRowView(from model: DeviceViewModel) -> DeviceRowView { - let view = DeviceRowView(viewModel: model) - - view.isHidden = true - view.alpha = 0 - - view.deleteHandler = { [weak self] _ in - view.showsActivityIndicator = true - - self?.handleDeviceDeletion?(view.viewModel) { - Task { @MainActor in - view.showsActivityIndicator = false - } - } - } - - return view - } - - private func updateView() { - titleLabel.text = titleText - messageLabel.text = messageText - continueButton.isEnabled = canContinue - statusImageView.style = canContinue ? .success : .failure - } - - 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/View controllers/DeviceList/DeviceManagementInteractor.swift b/ios/MullvadVPN/View controllers/DeviceList/DeviceManagementInteractor.swift deleted file mode 100644 index 652da7a7be..0000000000 --- a/ios/MullvadVPN/View controllers/DeviceList/DeviceManagementInteractor.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// DeviceManagementInteractor.swift -// MullvadVPN -// -// Created by pronebird on 26/07/2022. -// Copyright © 2025 Mullvad VPN AB. All rights reserved. -// - -import Foundation -import MullvadREST -import MullvadTypes -import Operations - -class DeviceManagementInteractor: @unchecked Sendable { - private let devicesProxy: DeviceHandling - private let accountNumber: String - - init(accountNumber: String, devicesProxy: DeviceHandling) { - self.accountNumber = accountNumber - self.devicesProxy = devicesProxy - } - - @discardableResult - func getDevices(_ completionHandler: @escaping @Sendable (Result<[Device], Error>) -> Void) -> Cancellable { - devicesProxy.getDevices( - accountNumber: accountNumber, - retryStrategy: .default, - completion: completionHandler - ) - } - - @discardableResult - func deleteDevice( - _ identifier: String, - completionHandler: @escaping @Sendable (Result<Bool, Error>) -> Void - ) -> Cancellable { - devicesProxy.deleteDevice( - accountNumber: accountNumber, - identifier: identifier, - retryStrategy: .default, - completion: completionHandler - ) - } -} diff --git a/ios/MullvadVPN/View controllers/DeviceList/DeviceManagementView.swift b/ios/MullvadVPN/View controllers/DeviceList/DeviceManagementView.swift new file mode 100644 index 0000000000..a7e05aa3f5 --- /dev/null +++ b/ios/MullvadVPN/View controllers/DeviceList/DeviceManagementView.swift @@ -0,0 +1,257 @@ +import MullvadTypes +import SwiftUI + +struct DeviceManagementView: View { + enum Style { + case tooManyDevices((Bool) -> Void) + case normal + + var actionButtonTitle: LocalizedStringKey { + switch self { + case .normal: + return "Remove" + case .tooManyDevices: + return "Yes, log out device" + } + } + + var actionButtonStyle: MainButtonStyle.Style { + switch self { + case .tooManyDevices: + .danger + case .normal: + .default + } + } + + func warningMessage(deviceName: String) -> LocalizedStringKey { + var attributedDeviceName: AttributedString { + var fullText = AttributedString(deviceName.capitalized) + fullText.foregroundColor = Color.mullvadTextPrimary + return fullText + } + return switch self { + case .tooManyDevices: + LocalizedStringKey( + "Are you sure you want to log \(attributedDeviceName) out?" + ) + case .normal: + LocalizedStringKey(""" + Remove \(attributedDeviceName)? + The device will be removed from the list and logged out. + """ + ) + } + } + } + + let deviceManaging: any DeviceManaging + let style: Style + let onError: (String, Error) -> Void + + @State private var loggedInDevices: [DeviceListView.Device] = [] + @State private var loading = true + + var canLoginNewDevice: Bool { + loggedInDevices.count < ApplicationConfiguration.maxAllowedDevices + } + + var bodyText: LocalizedStringKey { + switch style { + case .normal: + """ + View and manage all your logged in devices. \ + You can have up to 5 devices on one account at a time. \ + Each device gets a name when logged in to help you tell them apart easily. + """ + case .tooManyDevices: + """ + 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. + """ + } + } + + private func fetchDevices() { + loading = true + _ = deviceManaging.getDevices { result in + Task { @MainActor in + loading = false + switch result { + case let .success(devices): + self.loggedInDevices = devices.map { + DeviceListView.Device( + id: $0.id, + name: $0.name.capitalized, + created: $0.created, + isCurrentDevice: $0.id == self.deviceManaging.currentDeviceId, + isBeingRemoved: false + ) + } + case let .failure(error): + onError("Failed to fetch devices", error) + } + } + } + } + + @State var deviceManagementAlert: MullvadAlert? + var body: some View { + VStack { + if case .tooManyDevices = style { + VStack(alignment: .leading, spacing: 8) { + if canLoginNewDevice { + HStack { + Spacer() + Image.mullvadIconSuccess + Spacer() + } + Text("Super!") + .font(.mullvadBig) + .foregroundStyle(Color.mullvadTextPrimary) + } else { + HStack { + Spacer() + Image.mullvadIconFail + Spacer() + } + Text("Too many devices") + .font(.mullvadBig) + .foregroundStyle(Color.mullvadTextPrimary) + } + } + .padding( + EdgeInsets( + top: UIMetrics.contentLayoutMargins.top, + leading: UIMetrics.contentLayoutMargins.leading, + bottom: 0, + trailing: UIMetrics.contentLayoutMargins.trailing + ) + ) + } + HStack { + Text(bodyText) + .foregroundColor(.mullvadTextPrimary) + .opacity(0.6) + .font(.mullvadTinySemiBold) + Spacer() + } + .padding( + EdgeInsets( + top: 8, + leading: UIMetrics.contentLayoutMargins.leading, + bottom: 16, + trailing: UIMetrics.contentLayoutMargins.trailing + ) + ) + DeviceListView( + devices: $loggedInDevices, + loading: $loading, + onRemoveDevice: { device in + deviceManagementAlert = MullvadAlert( + type: .warning, + message: style.warningMessage(deviceName: device.name), + action: .init( + type: style.actionButtonStyle, + title: style.actionButtonTitle, + identifier: AccessibilityIdentifier.logOutDeviceConfirmButton, + handler: { + await withCheckedContinuation { continuation in + loggedInDevices = loggedInDevices.map { + $0.id == device.id ? $0.setIsBeingRemoved(true) : $0 + } + deviceManagementAlert = nil + _ = deviceManaging.deleteDevice( + device.id, + completionHandler: { result in + Task { @MainActor in + switch result { + case .success: + loggedInDevices.removeAll(where: { $0.id == device.id }) + case let .failure(error): + loggedInDevices = loggedInDevices.map { + $0.id == device + .id ? $0.setIsBeingRemoved(false) : $0 + } + onError("Failed to log out device", error) + } + continuation.resume() + } + } + ) + } + } + ), + dismissButtonTitle: "Cancel" + ) + } + ) + Spacer() + if case let .tooManyDevices(backToLogin) = style { + MainButton( + text: "Continue with login", + style: .success + ) { + backToLogin(true) + } + .accessibilityIdentifier(AccessibilityIdentifier.continueWithLoginButton) + .disabled(!canLoginNewDevice) + .padding( + EdgeInsets( + top: UIMetrics.contentLayoutMargins.top, + leading: UIMetrics.contentLayoutMargins.leading, + bottom: UIMetrics.contentLayoutMargins.bottom, + trailing: UIMetrics.contentLayoutMargins.trailing + ) + ) + } + } + .mullvadAlert(item: $deviceManagementAlert) + .background(Color.mullvadBackground) + .task { + fetchDevices() + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier( + .deviceManagementView + ) + } +} + +#Preview { + Text("Too many devices") + .sheet(isPresented: .constant(true)) { + DeviceManagementView( + deviceManaging: MockDeviceManaging(), + style: .tooManyDevices { _ in }, + onError: { _, _ in } + ) + } +} + +#Preview("Too many devices: Success") { + Text("") + .sheet(isPresented: .constant(true)) { + DeviceManagementView( + deviceManaging: MockDeviceManaging( + devicesToReturn: ApplicationConfiguration.maxAllowedDevices - 1 + ), + style: .tooManyDevices { _ in }, + onError: { _, _ in } + ) + } +} + +#Preview("Device Management") { + Text("") + .sheet(isPresented: .constant(true)) { + NavigationView { + DeviceManagementView( + deviceManaging: MockDeviceManaging(), + style: .normal, + onError: { _, _ in } + ) + .navigationTitle("Manage Devices") + } + } +} diff --git a/ios/MullvadVPN/View controllers/DeviceList/DeviceManagementViewController.swift b/ios/MullvadVPN/View controllers/DeviceList/DeviceManagementViewController.swift deleted file mode 100644 index b297401213..0000000000 --- a/ios/MullvadVPN/View controllers/DeviceList/DeviceManagementViewController.swift +++ /dev/null @@ -1,284 +0,0 @@ -// -// DeviceManagementViewController.swift -// MullvadVPN -// -// Created by pronebird on 15/07/2022. -// Copyright © 2025 Mullvad VPN AB. All rights reserved. -// - -@preconcurrency import MullvadLogging -import MullvadREST -import MullvadTypes -import Operations -import UIKit - -protocol DeviceManagementViewControllerDelegate: AnyObject, Sendable { - func deviceManagementViewControllerDidFinish(_ controller: DeviceManagementViewController) - func deviceManagementViewControllerDidCancel(_ controller: DeviceManagementViewController) -} - -class DeviceManagementViewController: UIViewController, RootContainment { - weak var delegate: DeviceManagementViewControllerDelegate? - - var preferredHeaderBarPresentation: HeaderBarPresentation { - .default - } - - var prefersHeaderBarHidden: Bool { - false - } - - override var preferredStatusBarStyle: UIStatusBarStyle { - .lightContent - } - - private let contentView: DeviceManagementContentView = { - let contentView = DeviceManagementContentView() - contentView.translatesAutoresizingMaskIntoConstraints = false - return contentView - }() - - nonisolated(unsafe) private let logger = Logger(label: "DeviceManagementViewController") - let interactor: DeviceManagementInteractor - private let alertPresenter: AlertPresenter - - init(interactor: DeviceManagementInteractor, alertPresenter: AlertPresenter) { - self.interactor = interactor - self.alertPresenter = alertPresenter - - 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 - - view.addSubview(contentView) - - contentView.cancelButton.addTarget( - self, - action: #selector(didTapBackButton(_:)), - for: .touchUpInside - ) - - contentView.continueButton.addTarget( - self, - action: #selector(didTapContinueButton(_:)), - for: .touchUpInside - ) - - contentView.handleDeviceDeletion = { [weak self] viewModel, finish in - Task { @MainActor in - self?.handleDeviceDeletion(viewModel, completionHandler: finish) - } - } - - NSLayoutConstraint.activate([ - contentView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), - contentView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - contentView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - contentView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - ]) - } - - func fetchDevices( - animateUpdates: Bool, - completionHandler: (@Sendable (Result<Void, Error>) -> Void)? = nil - ) { - interactor.getDevices { [weak self] result in - guard let self = self else { return } - - if let devices = result.value { - setDevices(devices, animated: animateUpdates) - } - - completionHandler?(result.map { _ in () }) - } - } - - // MARK: - Private - - nonisolated private func setDevices(_ devices: [Device], animated: Bool) { - let viewModels = devices.map { restDevice -> DeviceViewModel in - DeviceViewModel( - id: restDevice.id, - name: restDevice.name.capitalized, - creationDate: DateFormatter.localizedString( - from: restDevice.created, - dateStyle: .short, - timeStyle: .none - ) - ) - } - - Task { @MainActor in - contentView.canContinue = viewModels.count < ApplicationConfiguration.maxAllowedDevices - contentView.setDeviceViewModels(viewModels, animated: animated) - } - } - - private func handleDeviceDeletion( - _ device: DeviceViewModel, - completionHandler: @escaping @Sendable () -> Void - ) { - showLogoutConfirmation(deviceName: device.name) { [weak self] shouldDelete in - guard let self else { return } - - guard shouldDelete else { - completionHandler() - return - } - - deleteDevice(identifier: device.id) { [weak self] error in - guard let self = self else { return } - - if let error { - Task { @MainActor in - 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 let .network(urlError) = error as? REST.Error { - return urlError.localizedDescription - } else { - return error.localizedDescription - } - } - - private func showErrorAlert(title: String, error: Error) { - let presentation = AlertPresentation( - id: "delete-device-error-alert", - title: title, - message: getErrorDescription(error), - buttons: [ - AlertAction( - title: NSLocalizedString( - "ERROR_ALERT_OK_ACTION", - tableName: "DeviceManagement", - value: "Got it!", - comment: "" - ), - style: .default - ), - ] - ) - - alertPresenter.showAlert(presentation: presentation, animated: true) - } - - private func showLogoutConfirmation( - deviceName: String, - completion: @escaping (_ shouldDelete: Bool) -> Void - ) { - let text = String( - format: NSLocalizedString( - "DELETE_ALERT_TITLE", - tableName: "DeviceManagement", - value: "Are you sure you want to log **\(deviceName)** out?", - comment: "" - ) - ) - - let attributedText = NSAttributedString( - markdownString: text, - options: MarkdownStylingOptions(font: .preferredFont(forTextStyle: .body)) - ) - - let presentation = AlertPresentation( - id: "logout-confirmation-alert", - icon: .alert, - attributedMessage: attributedText, - buttons: [ - AlertAction( - title: NSLocalizedString( - "DELETE_ALERT_CONFIRM_ACTION", - tableName: "DeviceManagement", - value: "Yes, log out device", - comment: "" - ), - style: .destructive, - accessibilityId: .logOutDeviceConfirmButton, - handler: { - completion(true) - } - ), - AlertAction( - title: NSLocalizedString( - "DELETE_ALERT_CANCEL_ACTION", - tableName: "DeviceManagement", - value: "Back", - comment: "" - ), - style: .default, - accessibilityId: .logOutDeviceCancelButton, - handler: { - completion(false) - } - ), - ] - ) - - alertPresenter.showAlert(presentation: presentation, animated: true) - } - - private func deleteDevice(identifier: String, completionHandler: @escaping @Sendable (Error?) -> Void) { - interactor.deleteDevice(identifier) { [weak self] completion in - guard let self = self else { return } - - switch completion { - case .success: - Task { @MainActor in - fetchDevices(animateUpdates: true) { completion in - completionHandler(completion.error) - } - } - - case let .failure(error): - if error.isOperationCancellationError { - completionHandler(nil) - } else { - logger.error( - error: error, - message: "Failed to delete device." - ) - completionHandler(error) - } - } - } - } - - // MARK: - Actions - - @objc private func didTapBackButton(_ sender: Any?) { - delegate?.deviceManagementViewControllerDidCancel(self) - } - - @objc private func didTapContinueButton(_ sender: Any?) { - delegate?.deviceManagementViewControllerDidFinish(self) - } -} - -struct DeviceViewModel: Sendable { - let id: String - let name: String - let creationDate: String -} diff --git a/ios/MullvadVPN/View controllers/DeviceList/DeviceManaging.swift b/ios/MullvadVPN/View controllers/DeviceList/DeviceManaging.swift new file mode 100644 index 0000000000..c625f7dc6c --- /dev/null +++ b/ios/MullvadVPN/View controllers/DeviceList/DeviceManaging.swift @@ -0,0 +1,134 @@ +// +// DeviceManagementInteractor.swift +// MullvadVPN +// +// Created by pronebird on 26/07/2022. +// Copyright © 2025 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import MullvadREST +import MullvadTypes +import WireGuardKitTypes + +protocol DeviceManaging { + var currentDeviceId: String? { get } + func getDevices(_ completionHandler: @escaping @Sendable (Result<[Device], Error>) -> Void) -> Cancellable + func deleteDevice( + _ identifier: String, + completionHandler: @escaping @Sendable (Result<Bool, Error>) -> Void + ) -> Cancellable +} + +class DeviceManagementInteractor: DeviceManaging, @unchecked Sendable { + private let devicesProxy: DeviceHandling + private let accountNumber: String + let currentDeviceId: String? + + init(accountNumber: String, currentDeviceId: String? = nil, devicesProxy: DeviceHandling) { + self.accountNumber = accountNumber + self.devicesProxy = devicesProxy + self.currentDeviceId = currentDeviceId + } + + @discardableResult + func getDevices(_ completionHandler: @escaping @Sendable (Result<[Device], Error>) -> Void) -> Cancellable { + devicesProxy.getDevices( + accountNumber: accountNumber, + retryStrategy: .default, + completion: completionHandler + ) + } + + @discardableResult + func deleteDevice( + _ identifier: String, + completionHandler: @escaping @Sendable (Result<Bool, Error>) -> Void + ) -> Cancellable { + devicesProxy.deleteDevice( + accountNumber: accountNumber, + identifier: identifier, + retryStrategy: .default, + completion: completionHandler + ) + } +} + +class MockDeviceManaging: DeviceManaging { + let currentDeviceId: String? = "123" + let getDevicesCompletionHandler: (() -> Result<[Device], Error>)? + static private let mockDevices = [ + Device( + id: "123", + name: "Blind Mole", + pubkey: PrivateKey().publicKey, + hijackDNS: false, + created: Date(), + ipv4Address: IPAddressRange(from: "127.0.0.1/32")!, + ipv6Address: IPAddressRange(from: "::ff/64")! + ), + Device( + id: "456", + name: "Tall Mole", + pubkey: PrivateKey().publicKey, + hijackDNS: false, + created: Date(), + ipv4Address: IPAddressRange(from: "127.0.0.1/32")!, + ipv6Address: IPAddressRange(from: "::ff/64")! + ), + Device( + id: "543", + name: "Old Mole", + pubkey: PrivateKey().publicKey, + hijackDNS: false, + created: Date(), + ipv4Address: IPAddressRange(from: "127.0.0.1/32")!, + ipv6Address: IPAddressRange(from: "::ff/64")! + ), + Device( + id: "867", + name: "Young Mole", + pubkey: PrivateKey().publicKey, + hijackDNS: false, + created: Date(), + ipv4Address: IPAddressRange(from: "127.0.0.1/32")!, + ipv6Address: IPAddressRange(from: "::ff/64")! + ), + Device( + id: "234", + name: "Rich Mole", + pubkey: PrivateKey().publicKey, + hijackDNS: false, + created: Date(), + ipv4Address: IPAddressRange(from: "127.0.0.1/32")!, + ipv6Address: IPAddressRange(from: "::ff/64")! + ), + ] + let devicesToReturn: Int + init( + devicesToReturn: Int = 5, + getDevicesCompletionHandler: (() -> Result<[Device], Error>)? = { + .success(mockDevices) + } + ) { + self.devicesToReturn = devicesToReturn + self.getDevicesCompletionHandler = getDevicesCompletionHandler + } + + func deleteDevice( + _ identifier: String, + completionHandler: @escaping @Sendable (Result<Bool, any Error>) -> Void + ) -> any MullvadTypes.Cancellable { + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + completionHandler(.success(true)) + } + return AnyCancellable() + } + + func getDevices(_ completionHandler: @escaping @Sendable (Result<[Device], Error>) -> Void) -> Cancellable { + if let getDevicesCompletionHandler { + completionHandler(getDevicesCompletionHandler().map { Array($0.prefix(devicesToReturn)) }) + } + return AnyCancellable() + } +} diff --git a/ios/MullvadVPN/View controllers/DeviceList/DeviceRowView.swift b/ios/MullvadVPN/View controllers/DeviceList/DeviceRowView.swift deleted file mode 100644 index b93b6ce5a5..0000000000 --- a/ios/MullvadVPN/View controllers/DeviceList/DeviceRowView.swift +++ /dev/null @@ -1,135 +0,0 @@ -// -// DeviceRowView.swift -// MullvadVPN -// -// Created by pronebird on 26/07/2022. -// Copyright © 2025 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 creationDateLabel: UILabel = { - let creationDateLabel = UILabel() - creationDateLabel.translatesAutoresizingMaskIntoConstraints = false - creationDateLabel.font = UIFont.systemFont(ofSize: 14) - creationDateLabel.textColor = .white.withAlphaComponent(0.6) - return creationDateLabel - }() - - let removeButton: UIButton = { - let image = UIImage.Buttons.close - .withTintColor( - .white.withAlphaComponent(0.4), - renderingMode: .alwaysOriginal - ) - - let button = IncreasedHitButton(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 = false { - didSet { - removeButton.isHidden = showsActivityIndicator - - if showsActivityIndicator { - activityIndicator.startAnimating() - } else { - activityIndicator.stopAnimating() - } - } - } - - init(viewModel: DeviceViewModel) { - self.viewModel = viewModel - - super.init(frame: .zero) - - setAccessibilityIdentifier(.deviceCell) - backgroundColor = .primaryColor - directionalLayoutMargins = UIMetrics.TableView.rowViewLayoutMargins - - for subview in [textLabel, removeButton, activityIndicator, creationDateLabel] { - addSubview(subview) - } - - textLabel.text = viewModel.name - creationDateLabel.text = .init( - format: - NSLocalizedString( - "CREATED_DEVICE_LABEL", - tableName: "DeviceManagement", - value: "Created: %@", - comment: "" - ), - viewModel.creationDate - ) - - removeButton.addTarget(self, action: #selector(handleButtonTap(_:)), for: .touchUpInside) - removeButton.setAccessibilityIdentifier(.deviceCellRemoveButton) - - NSLayoutConstraint.activate([ - textLabel.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor), - textLabel.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), - - creationDateLabel.leadingAnchor.constraint(equalTo: textLabel.leadingAnchor), - creationDateLabel.topAnchor.constraint(equalTo: textLabel.bottomAnchor, constant: 4.0), - creationDateLabel.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor) - .withPriority(.defaultLow), - creationDateLabel.trailingAnchor.constraint(equalTo: textLabel.trailingAnchor), - - removeButton.centerYAnchor.constraint(equalTo: layoutMarginsGuide.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/Views/ClearBackgroundView.swift b/ios/MullvadVPN/Views/ClearBackgroundView.swift new file mode 100644 index 0000000000..7f25f2e0cb --- /dev/null +++ b/ios/MullvadVPN/Views/ClearBackgroundView.swift @@ -0,0 +1,17 @@ +import SwiftUI + +struct ClearBackgroundView: UIViewRepresentable { + func makeUIView(context: Context) -> UIView { + return InnerView() + } + + func updateUIView(_ uiView: UIView, context: Context) {} + + private class InnerView: UIView { + override func didMoveToWindow() { + super.didMoveToWindow() + + superview?.superview?.backgroundColor = .init(red: 0, green: 0, blue: 0, alpha: 0.5) + } + } +} diff --git a/ios/MullvadVPN/Views/List/MullvadListActionItemView.swift b/ios/MullvadVPN/Views/List/MullvadListActionItemView.swift new file mode 100644 index 0000000000..195ebea091 --- /dev/null +++ b/ios/MullvadVPN/Views/List/MullvadListActionItemView.swift @@ -0,0 +1,110 @@ +import SwiftUI + +struct MullvadListActionItem: Hashable, Identifiable { + var id: String + let title: LocalizedStringKey + let state: LocalizedStringKey? + let detail: LocalizedStringKey? + let accessibilityIdentifier: AccessibilityIdentifier? + let pressed: (() -> Void)? + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + static func == (lhs: MullvadListActionItem, rhs: MullvadListActionItem) -> Bool { + lhs.id == rhs.id + } +} + +struct MullvadListActionItemView<Icon: View>: View { + private let title: LocalizedStringKey + private let state: LocalizedStringKey? + private let detail: LocalizedStringKey? + private let icon: Icon? + private let accessibilityIdentifier: AccessibilityIdentifier? + private let pressed: (() -> Void)? + + init( + item: MullvadListActionItem, + @ViewBuilder icon: () -> Icon? + ) { + self.title = item.title + self.state = item.state + self.detail = item.detail + self.accessibilityIdentifier = item.accessibilityIdentifier + self.pressed = item.pressed + self.icon = icon() + } + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .foregroundStyle(Color(.Cell.titleTextColor)) + .font(.mullvadSmallSemiBold) + if let detail { + Text(detail) + .foregroundStyle(Color(.Cell.detailTextColor.withAlphaComponent(0.6))) + .font(.mullvadMiniSemiBold) + } + } + Spacer() + if let state { + Text(state) + .foregroundStyle(Color(.Cell.titleTextColor.withAlphaComponent(0.6))) + .font(.mullvadTiny) + } + if let icon { + Button { + pressed?() + } label: { + icon + } + .accessibilityIdentifier(accessibilityIdentifier) + } + } + .padding(EdgeInsets( + top: 12, + leading: UIMetrics.contentLayoutMargins.leading, + bottom: 12, + trailing: UIMetrics.contentLayoutMargins.trailing + )) + .frame(minHeight: UIMetrics.TableView.rowHeight, maxHeight: .infinity) + .background(Color.MullvadList.background) + } +} + +#Preview { + Text("") + .sheet(isPresented: .constant(true)) { + MullvadList( + [ + MullvadListActionItem( + id: "1", + title: "Blind mole", + state: nil, + detail: "Created: 2024-05-08", + accessibilityIdentifier: nil, + pressed: { + print("selected") + } + ), + MullvadListActionItem( + id: "2", + title: "Tall mole", + state: "Current Device", + detail: "Created: 2024-05-08", + accessibilityIdentifier: nil, + pressed: nil + ), + ] + ) { item in + MullvadListActionItemView(item: item) { + if item.pressed != nil { + Image.mullvadIconClose + } + } + } + } +} diff --git a/ios/MullvadVPN/Views/MullvadAlert.swift b/ios/MullvadVPN/Views/MullvadAlert.swift new file mode 100644 index 0000000000..7f97861122 --- /dev/null +++ b/ios/MullvadVPN/Views/MullvadAlert.swift @@ -0,0 +1,111 @@ +import SwiftUI + +struct MullvadAlert: Identifiable { + enum AlertType { + case warning + case error + } + + enum ActionType { + case danger + case normal + } + + struct Action { + let type: MainButtonStyle.Style + let title: LocalizedStringKey + let identifier: AccessibilityIdentifier? + let handler: () async -> Void + } + + let id = UUID() + let type: AlertType + let message: LocalizedStringKey + let action: Action? + let dismissButtonTitle: LocalizedStringKey +} + +struct AlertModifier: ViewModifier { + @Binding var alert: MullvadAlert? + @State var loading = false + func body(content: Content) -> some View { + content + .fullScreenCover(item: $alert) { alert in + VStack { + Spacer() + VStack(spacing: 16) { + switch alert.type { + case .error, .warning: + Image.mullvadIconAlert + .resizable() + .frame(width: 48, height: 48) + } + HStack { + Text(alert.message) + .font(.mullvadSmall) + .foregroundColor(.mullvadTextPrimary.opacity(0.6)) + Spacer() + } + VStack(spacing: 16) { + if let action = alert.action { + MainButton( + text: action.title, + style: action.type, + action: { + Task { + loading = true + await action.handler() + loading = false + } + } + ) + .accessibilityIdentifier(action.identifier) + } + MainButton( + text: alert.dismissButtonTitle, + style: .default, + action: { self.alert = nil } + ) + } + } + .padding() + .background(Color.mullvadBackground) + .cornerRadius(8) + Spacer() + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier(.alertContainerView) + .padding() + .background(ClearBackgroundView()) + } + .transaction { + $0.disablesAnimations = true + } + } +} + +extension View { + func mullvadAlert(item: Binding<MullvadAlert?>) -> some View { + modifier(AlertModifier(alert: item)) + } +} + +#Preview { + Text("Hello, World!") + .mullvadAlert( + item: + .constant( + .init( + type: .warning, + message: "Something needs to be done", + action: .init( + type: .danger, + title: "Do it!", + identifier: nil, + handler: {} + ), + dismissButtonTitle: "Cancel" + ) + ) + ) +} diff --git a/ios/MullvadVPN/Views/MullvadProgressViewStyle.swift b/ios/MullvadVPN/Views/MullvadProgressViewStyle.swift new file mode 100644 index 0000000000..80a3b4bbef --- /dev/null +++ b/ios/MullvadVPN/Views/MullvadProgressViewStyle.swift @@ -0,0 +1,24 @@ +import SwiftUI + +struct MullvadProgressViewStyle: ProgressViewStyle { + @State var isAnimating = false + func makeBody(configuration: Configuration) -> some View { + Image.mullvadIconSpinner + .resizable() + .frame(maxWidth: 48, maxHeight: 48) + .rotationEffect(.degrees(isAnimating ? 360 : 0)) + .onAppear { + withAnimation( + .linear(duration: 0.6).repeatForever(autoreverses: false) + ) { + isAnimating = true + } + } + } +} + +#Preview { + ProgressView() + .progressViewStyle(MullvadProgressViewStyle()) + .background(Color.mullvadBackground) +} diff --git a/ios/MullvadVPNUITests/AccountTests.swift b/ios/MullvadVPNUITests/AccountTests.swift index 5c3d2a994c..97d711ec50 100644 --- a/ios/MullvadVPNUITests/AccountTests.swift +++ b/ios/MullvadVPNUITests/AccountTests.swift @@ -53,6 +53,71 @@ class AccountTests: LoggedOutUITestCase { .verifyFailIconShown() } + func testCanNotRemoveCurrentDevice() throws { + // Setup + let temporaryAccountNumber = createTemporaryAccountWithoutTime() + + // Teardown + addTeardownBlock { + self.mullvadAPIWrapper.deleteAccount(temporaryAccountNumber) + } + + LoginPage(app) + .tapAccountNumberTextField() + .enterText(temporaryAccountNumber) + .tapAccountNumberSubmitButton() + + OutOfTimePage(app) + + HeaderBar(app) + .tapAccountButton() + + AccountPage(app) + .tapDeviceManagementButton() + + DeviceManagementPage(app) + .verifyCurrentDeviceExists() + .verifyNoDeviceCanBeRemoved() + } + + func testRemoveOtherDevice() throws { + let otherDevicesCount = 2 + // Setup + let temporaryAccountNumber = createTemporaryAccountWithoutTime() + mullvadAPIWrapper.addDevices(otherDevicesCount, account: temporaryAccountNumber) + + // Teardown + addTeardownBlock { + self.mullvadAPIWrapper.deleteAccount(temporaryAccountNumber) + } + + LoginPage(app) + .tapAccountNumberTextField() + .enterText(temporaryAccountNumber) + .tapAccountNumberSubmitButton() + + OutOfTimePage(app) + + HeaderBar(app) + .tapAccountButton() + + AccountPage(app) + .tapDeviceManagementButton() + + DeviceManagementPage(app) + .waitForDeviceList() + .verifyRemovableDeviceCount(otherDevicesCount) + .tapRemoveDeviceButton(cellIndex: 1) + + DeviceManagementLogOutDeviceConfirmationAlert(app) + .tapYesLogOutDeviceButton() + + DeviceManagementPage(app) + .waitForDeviceList() + .waitForNoLoading() + .verifyRemovableDeviceCount(otherDevicesCount - 1) + } + /// Verify logging in works. Will retry x number of times since login request sometimes time out. func testLogin() throws { let hasTimeAccountNumber = getAccountWithTime() @@ -106,12 +171,15 @@ class AccountTests: LoggedOutUITestCase { .tapAccountNumberSubmitButton() DeviceManagementPage(app) + .waitForDeviceList() .tapRemoveDeviceButton(cellIndex: 0) DeviceManagementLogOutDeviceConfirmationAlert(app) .tapYesLogOutDeviceButton() DeviceManagementPage(app) + .waitForDeviceList() + .waitForNoLoading() .tapContinueWithLoginButton() // First taken back to login page and automatically being logged in diff --git a/ios/MullvadVPNUITests/Pages/AccountPage.swift b/ios/MullvadVPNUITests/Pages/AccountPage.swift index 4c06cccda1..821b35e539 100644 --- a/ios/MullvadVPNUITests/Pages/AccountPage.swift +++ b/ios/MullvadVPNUITests/Pages/AccountPage.swift @@ -42,6 +42,11 @@ class AccountPage: Page { return self } + @discardableResult func tapDeviceManagementButton() -> Self { + app.buttons[AccessibilityIdentifier.deviceManagementButton.asString].tap() + return self + } + func getDeviceName() throws -> String { let deviceNameLabel = app.otherElements[AccessibilityIdentifier.accountPageDeviceNameLabel] return try XCTUnwrap(deviceNameLabel.value as? String, "Failed to read device name from label") diff --git a/ios/MullvadVPNUITests/Pages/DeviceManagementPage.swift b/ios/MullvadVPNUITests/Pages/DeviceManagementPage.swift index 62cc056f45..622838a8a9 100644 --- a/ios/MullvadVPNUITests/Pages/DeviceManagementPage.swift +++ b/ios/MullvadVPNUITests/Pages/DeviceManagementPage.swift @@ -13,13 +13,36 @@ class DeviceManagementPage: Page { override init(_ app: XCUIApplication) { super.init(app) - self.pageElement = app.otherElements[.deviceManagementView] + self.pageElement = app + .descendants(matching: .any) + .matching( + identifier: AccessibilityIdentifier.deviceManagementView.asString + ).element waitForPageToBeShown() } + @discardableResult func waitForNoLoading() -> Self { + XCTAssertTrue( + app.otherElements[.deviceRemovalProgressView] + .waitForNonExistence(timeout: BaseUITestCase.longTimeout) + ) + + return self + } + + @discardableResult func waitForDeviceList() -> Self { + XCTAssertTrue( + app + .collectionViews[AccessibilityIdentifier.deviceListView] + .waitForExistence(timeout: BaseUITestCase.longTimeout) + ) + + return self + } + @discardableResult func tapRemoveDeviceButton(cellIndex: Int) -> Self { app - .otherElements.matching(identifier: AccessibilityIdentifier.deviceCell.asString).element(boundBy: cellIndex) + .cells.element(boundBy: cellIndex) .buttons[AccessibilityIdentifier.deviceCellRemoveButton] .tap() @@ -30,6 +53,37 @@ class DeviceManagementPage: Page { app.buttons[AccessibilityIdentifier.continueWithLoginButton].tap() return self } + + @discardableResult public func verifyCurrentDeviceExists() -> Self { + XCTAssertTrue( + app.staticTexts["Current device"] + .waitForExistence(timeout: BaseUITestCase.defaultTimeout) + ) + + return self + } + + @discardableResult public func verifyNoDeviceCanBeRemoved() -> Self { + XCTAssertTrue( + app + .buttons[AccessibilityIdentifier.deviceCellRemoveButton] + .waitForNonExistence(timeout: BaseUITestCase.defaultTimeout) + ) + + return self + } + + @discardableResult public func verifyRemovableDeviceCount(_ expectedCount: Int) -> Self { + XCTAssertEqual( + app.buttons.matching( + identifier: AccessibilityIdentifier.deviceCellRemoveButton.asString + ) + .count, + + expectedCount + ) + return self + } } /// Confirmation alert displayed when removing a device |
