summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorJon Petersson <jon.petersson@mullvad.net>2025-06-23 11:26:44 +0200
committerJon Petersson <jon.petersson@mullvad.net>2025-06-23 11:26:44 +0200
commit741cfd8a3ffe1152738773c8ac256ac78c2e28cb (patch)
treec5f7d3cf790b9da2ce4a8bbc80002b38a4d965db
parent9cc76d2df81dab2d757478e499fd1f28b0195b3f (diff)
parentce6fb13d38ce48e33a42b65ed7f506dc414e4f8b (diff)
downloadmullvadvpn-741cfd8a3ffe1152738773c8ac256ac78c2e28cb.tar.xz
mullvadvpn-741cfd8a3ffe1152738773c8ac256ac78c2e28cb.zip
Merge branch 'implement-device-management-design-ios-1173'
-rw-r--r--ios/CHANGELOG.md1
-rw-r--r--ios/MullvadVPN.xcodeproj/project.pbxproj51
-rw-r--r--ios/MullvadVPN/Classes/AccessbilityIdentifier.swift3
-rw-r--r--ios/MullvadVPN/Coordinators/AccountCoordinator.swift105
-rw-r--r--ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift3
-rw-r--r--ios/MullvadVPN/Coordinators/LoginCoordinator.swift75
-rw-r--r--ios/MullvadVPN/Extensions/Image+Assets.swift13
-rw-r--r--ios/MullvadVPN/Extensions/View+Modifier.swift15
-rw-r--r--ios/MullvadVPN/UI appearance/Color+Mullvad.swift1
-rw-r--r--ios/MullvadVPN/View controllers/Account/AccountDeviceRow.swift44
-rw-r--r--ios/MullvadVPN/View controllers/Account/AccountInteractor.swift5
-rw-r--r--ios/MullvadVPN/View controllers/Account/AccountViewController.swift6
-rw-r--r--ios/MullvadVPN/View controllers/DeviceList/DeviceListView.swift92
-rw-r--r--ios/MullvadVPN/View controllers/DeviceList/DeviceManagementContentView.swift319
-rw-r--r--ios/MullvadVPN/View controllers/DeviceList/DeviceManagementInteractor.swift44
-rw-r--r--ios/MullvadVPN/View controllers/DeviceList/DeviceManagementView.swift257
-rw-r--r--ios/MullvadVPN/View controllers/DeviceList/DeviceManagementViewController.swift284
-rw-r--r--ios/MullvadVPN/View controllers/DeviceList/DeviceManaging.swift134
-rw-r--r--ios/MullvadVPN/View controllers/DeviceList/DeviceRowView.swift135
-rw-r--r--ios/MullvadVPN/Views/ClearBackgroundView.swift17
-rw-r--r--ios/MullvadVPN/Views/List/MullvadListActionItemView.swift110
-rw-r--r--ios/MullvadVPN/Views/MullvadAlert.swift111
-rw-r--r--ios/MullvadVPN/Views/MullvadProgressViewStyle.swift24
-rw-r--r--ios/MullvadVPNUITests/AccountTests.swift68
-rw-r--r--ios/MullvadVPNUITests/Pages/AccountPage.swift5
-rw-r--r--ios/MullvadVPNUITests/Pages/DeviceManagementPage.swift58
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