summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAndrej Mihajlov <and@mullvad.net>2021-11-03 13:35:04 +0100
committerAndrej Mihajlov <and@mullvad.net>2021-11-03 13:35:04 +0100
commitec21ef1a55b72182d2d05fc0a04c6d405934df62 (patch)
treed5860757d53c956060d51f4436c4a85fd2a8fadd
parent7ec35a8a320b601305b9f2d796368d785faf58f8 (diff)
parente2c608bf069f9f53de8505c2744bbd4bfee9cc8d (diff)
downloadmullvadvpn-ec21ef1a55b72182d2d05fc0a04c6d405934df62.tar.xz
mullvadvpn-ec21ef1a55b72182d2d05fc0a04c6d405934df62.zip
Merge branch 'custom-dns-ios'
-rw-r--r--README.md2
-rw-r--r--ios/CHANGELOG.md4
-rw-r--r--ios/MullvadVPN.xcodeproj/project.pbxproj60
-rw-r--r--ios/MullvadVPN/AnyIPAddress.swift101
-rw-r--r--ios/MullvadVPN/BasicTableViewCell.swift32
-rw-r--r--ios/MullvadVPN/CharacterSet+IPAddress.swift20
-rw-r--r--ios/MullvadVPN/CustomSwitch.swift10
-rw-r--r--ios/MullvadVPN/CustomSwitchContainer.swift21
-rw-r--r--ios/MullvadVPN/CustomTextField.swift18
-rw-r--r--ios/MullvadVPN/DataSourceSnapshot.swift447
-rw-r--r--ios/MullvadVPN/EmbeddedViewContainerView.swift60
-rw-r--r--ios/MullvadVPN/PreferencesDataSource.swift594
-rw-r--r--ios/MullvadVPN/PreferencesDataSourceDelegate.swift13
-rw-r--r--ios/MullvadVPN/PreferencesViewController.swift97
-rw-r--r--ios/MullvadVPN/PreferencesViewModel.swift164
-rw-r--r--ios/MullvadVPN/SelectLocationCell.swift30
-rw-r--r--ios/MullvadVPN/SettingsAddDNSEntryCell.swift32
-rw-r--r--ios/MullvadVPN/SettingsCell.swift103
-rw-r--r--ios/MullvadVPN/SettingsDNSTextCell.swift139
-rw-r--r--ios/MullvadVPN/SettingsStaticTextFooterView.swift41
-rw-r--r--ios/MullvadVPN/SettingsSwitchCell.swift24
-rw-r--r--ios/MullvadVPN/TunnelManager/TunnelManager.swift44
-rw-r--r--ios/MullvadVPN/TunnelSettings.swift44
-rw-r--r--ios/MullvadVPN/UIColor+Palette.swift4
-rw-r--r--ios/MullvadVPN/UIMetrics.swift9
-rw-r--r--ios/MullvadVPN/en.lproj/Preferences.strings18
-rw-r--r--ios/MullvadVPN/en.lproj/Settings.strings3
-rw-r--r--ios/MullvadVPNTests/DataSourceSnapshotTests.swift147
-rw-r--r--ios/PacketTunnel/PacketTunnelProvider.swift25
29 files changed, 2048 insertions, 258 deletions
diff --git a/README.md b/README.md
index 778af3a1cc..c1133f457f 100644
--- a/README.md
+++ b/README.md
@@ -51,7 +51,7 @@ state of latest master, not necessarily any existing release.
| WireGuard | ✓ | ✓ | ✓ | ✓ | ✓ |
| OpenVPN over Shadowsocks | ✓ | ✓ | ✓ | | |
| Split tunneling | ✓ | ✓ | | ✓ | |
-| Custom DNS server | ✓ | ✓ | ✓ | ✓ | |
+| Custom DNS server | ✓ | ✓ | ✓ | ✓ | ✓ |
| Ad and tracker blocking | ✓ | ✓ | ✓ | | ✓ |
| Optional local network access | ✓ | ✓ | ✓ | ✓ | ✓\* |
diff --git a/ios/CHANGELOG.md b/ios/CHANGELOG.md
index 77cb99f804..284fc1d99a 100644
--- a/ios/CHANGELOG.md
+++ b/ios/CHANGELOG.md
@@ -23,10 +23,14 @@ Line wrap the file at 100 chars. Th
## [Unreleased]
+### Added
+- Add ability to specify custom DNS servers.
+
### Changed
- Attach log backup from previous application run to problem report.
- Use background tasks to periodically update relays and rotate the private key on iOS 13 or newer.
Background fetch is used as fallback on iOS 12.
+- Request background execution time from the system when performing critical tasks.
### Fixed
- Drop leading replacement characters (`\u{FFFD}`) when decoding UTF-8 from a part of log file.
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj
index 4417fd14e9..ec6cdb1084 100644
--- a/ios/MullvadVPN.xcodeproj/project.pbxproj
+++ b/ios/MullvadVPN.xcodeproj/project.pbxproj
@@ -45,6 +45,9 @@
581503A424D6F1EC00C9C50E /* ChainedError+Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 581503A224D6F1EC00C9C50E /* ChainedError+Logger.swift */; };
581503A624D6F4AE00C9C50E /* Logging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 581503A524D6F4AE00C9C50E /* Logging.swift */; };
581503A724D6F4AE00C9C50E /* Logging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 581503A524D6F4AE00C9C50E /* Logging.swift */; };
+ 5819C2142726CC8D00D6EC38 /* DataSourceSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5819C2132726CC8D00D6EC38 /* DataSourceSnapshotTests.swift */; };
+ 5819C2152726CC9400D6EC38 /* DataSourceSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587EB66F27143B6500123C75 /* DataSourceSnapshot.swift */; };
+ 5819C2172729595500D6EC38 /* SettingsAddDNSEntryCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5819C2162729595500D6EC38 /* SettingsAddDNSEntryCell.swift */; };
581CBCEE229826FD00727D7F /* StaticTableViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 581CBCED229826FD00727D7F /* StaticTableViewDataSource.swift */; };
581FC4FA2695ACE100AA97BA /* Account.strings in Resources */ = {isa = PBXBuildFile; fileRef = 581FC4F82695ACE100AA97BA /* Account.strings */; };
5820674926E63EC900655B05 /* Promise+BackgroundTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5820674826E63EC800655B05 /* Promise+BackgroundTask.swift */; };
@@ -95,9 +98,13 @@
5846227726E22A7C0035F7C2 /* AppStorePaymentManagerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5846227626E22A7C0035F7C2 /* AppStorePaymentManagerDelegate.swift */; };
5846227A26E24F1F0035F7C2 /* ExclusivityController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 580EE20524B3222200F9D8A1 /* ExclusivityController.swift */; };
584789BE264D4A2A000E45FB /* le_root_cert.cer in Resources */ = {isa = PBXBuildFile; fileRef = 584789B7264D4A2A000E45FB /* le_root_cert.cer */; };
- 584789BE264D4A2A000E45FB /* le_root_cert.cer in Resources */ = {isa = PBXBuildFile; fileRef = 584789B7264D4A2A000E45FB /* le_root_cert.cer */; };
584789E026529D72000E45FB /* SSLPinningURLSessionDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 584789DF26529D72000E45FB /* SSLPinningURLSessionDelegate.swift */; };
584789EC2652A1A2000E45FB /* Logging in Frameworks */ = {isa = PBXBuildFile; productRef = 584789EB2652A1A2000E45FB /* Logging */; };
+ 584D26BF270C550B004EA533 /* AnyIPAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 584D26BE270C550B004EA533 /* AnyIPAddress.swift */; };
+ 584D26C0270C550E004EA533 /* AnyIPAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 584D26BE270C550B004EA533 /* AnyIPAddress.swift */; };
+ 584D26C2270C8542004EA533 /* SettingsStaticTextFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 584D26C1270C8542004EA533 /* SettingsStaticTextFooterView.swift */; };
+ 584D26C4270C855B004EA533 /* PreferencesDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 584D26C3270C855A004EA533 /* PreferencesDataSource.swift */; };
+ 584D26C6270C8741004EA533 /* SettingsDNSTextCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 584D26C5270C8741004EA533 /* SettingsDNSTextCell.swift */; };
584E96BC240FD4DA00D3334F /* Location.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58A1AA8623F43901009F7EA6 /* Location.swift */; };
584E96BD240FD4DA00D3334F /* Location.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58A1AA8623F43901009F7EA6 /* Location.swift */; };
584E96BE240FD4DB00D3334F /* Location.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58A1AA8623F43901009F7EA6 /* Location.swift */; };
@@ -149,7 +156,6 @@
5871FB96254ADE4E0051A0A4 /* ConsolidatedApplicationLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5871FB95254ADE4E0051A0A4 /* ConsolidatedApplicationLog.swift */; };
5871FBA0254C26C00051A0A4 /* NSRegularExpression+IPAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5871FB9F254C26BF0051A0A4 /* NSRegularExpression+IPAddress.swift */; };
58727283265D173C00F315B2 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 58727282265D173C00F315B2 /* LaunchScreen.storyboard */; };
- 5873884D239E6D7E00E96C4E /* EmbeddedViewContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5873884C239E6D7E00E96C4E /* EmbeddedViewContainerView.swift */; };
587425C12299833500CA2045 /* RootContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587425C02299833500CA2045 /* RootContainerViewController.swift */; };
5875960726F36B3A00BF6711 /* TunnelIPCError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5875960626F36B3A00BF6711 /* TunnelIPCError.swift */; };
5875960A26F371FC00BF6711 /* TunnelIPCSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5875960926F371FC00BF6711 /* TunnelIPCSession.swift */; };
@@ -171,6 +177,10 @@
587C575326D2615F005EF767 /* PacketTunnelOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587C575226D2615F005EF767 /* PacketTunnelOptions.swift */; };
587C575426D2615F005EF767 /* PacketTunnelOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587C575226D2615F005EF767 /* PacketTunnelOptions.swift */; };
587CBFE322807F530028DED3 /* UIColor+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587CBFE222807F530028DED3 /* UIColor+Helpers.swift */; };
+ 587EB66A270EFACB00123C75 /* CharacterSet+IPAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587EB669270EFACB00123C75 /* CharacterSet+IPAddress.swift */; };
+ 587EB67027143B6500123C75 /* DataSourceSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587EB66F27143B6500123C75 /* DataSourceSnapshot.swift */; };
+ 587EB672271451E300123C75 /* PreferencesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587EB671271451E300123C75 /* PreferencesViewModel.swift */; };
+ 587EB6742714520600123C75 /* PreferencesDataSourceDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587EB6732714520600123C75 /* PreferencesDataSourceDelegate.swift */; };
5883A09E266A5AF7003EFFCB /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 587B7543266922BF00DEF7E9 /* Localizable.strings */; };
58871D1E25D535A3002297FA /* WireGuardKit in Frameworks */ = {isa = PBXBuildFile; productRef = 58871D1D25D535A3002297FA /* WireGuardKit */; };
58871D2325D535D2002297FA /* IPAddressRange+Codable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5850366725A47AC700A43E93 /* IPAddressRange+Codable.swift */; };
@@ -192,8 +202,8 @@
5896AE86246D6AD8005B36CB /* CustomDateComponentsFormattingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5896AE85246D6AD8005B36CB /* CustomDateComponentsFormattingTests.swift */; };
5896AE88246D7FAF005B36CB /* CustomDateComponentsFormatting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5896AE83246D5889005B36CB /* CustomDateComponentsFormatting.swift */; };
5896CEF226972DEB00B0FAE8 /* AccountContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5896CEF126972DEB00B0FAE8 /* AccountContentView.swift */; };
- 589AB4F7227B64450039131E /* BasicTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 589AB4F6227B64450039131E /* BasicTableViewCell.swift */; };
58A1AA8C23F5584C009F7EA6 /* ConnectionPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58A1AA8B23F5584B009F7EA6 /* ConnectionPanelView.swift */; };
+ 58A8055E2716EA6700681642 /* AnyIPAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 584D26BE270C550B004EA533 /* AnyIPAddress.swift */; };
58A8BE81239FBE62006B74AC /* IPEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58561C98239A5D1500BD6B5E /* IPEndpoint.swift */; };
58A94AE626D23C3D001CB97C /* PromiseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58A94AE526D23C3D001CB97C /* PromiseTests.swift */; };
58A99ED3240014A0006599E9 /* ConsentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58A99ED2240014A0006599E9 /* ConsentViewController.swift */; };
@@ -353,6 +363,8 @@
5815039C24D6ECE600C9C50E /* TextFileOutputStream.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextFileOutputStream.swift; sourceTree = "<group>"; };
581503A224D6F1EC00C9C50E /* ChainedError+Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ChainedError+Logger.swift"; sourceTree = "<group>"; };
581503A524D6F4AE00C9C50E /* Logging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logging.swift; sourceTree = "<group>"; };
+ 5819C2132726CC8D00D6EC38 /* DataSourceSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataSourceSnapshotTests.swift; sourceTree = "<group>"; };
+ 5819C2162729595500D6EC38 /* SettingsAddDNSEntryCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAddDNSEntryCell.swift; sourceTree = "<group>"; };
581CBCED229826FD00727D7F /* StaticTableViewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StaticTableViewDataSource.swift; sourceTree = "<group>"; };
581FC4F92695ACE100AA97BA /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Account.strings; sourceTree = "<group>"; };
5820674826E63EC800655B05 /* Promise+BackgroundTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Promise+BackgroundTask.swift"; sourceTree = "<group>"; };
@@ -394,6 +406,10 @@
584789B7264D4A2A000E45FB /* le_root_cert.cer */ = {isa = PBXFileReference; lastKnownFileType = file; path = le_root_cert.cer; sourceTree = "<group>"; };
584789DF26529D72000E45FB /* SSLPinningURLSessionDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSLPinningURLSessionDelegate.swift; sourceTree = "<group>"; };
584B26F3237434D00073B10E /* RelaySelectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelaySelectorTests.swift; sourceTree = "<group>"; };
+ 584D26BE270C550B004EA533 /* AnyIPAddress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyIPAddress.swift; sourceTree = "<group>"; };
+ 584D26C1270C8542004EA533 /* SettingsStaticTextFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsStaticTextFooterView.swift; sourceTree = "<group>"; };
+ 584D26C3270C855A004EA533 /* PreferencesDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesDataSource.swift; sourceTree = "<group>"; };
+ 584D26C5270C8741004EA533 /* SettingsDNSTextCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsDNSTextCell.swift; sourceTree = "<group>"; };
5850366725A47AC700A43E93 /* IPAddressRange+Codable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "IPAddressRange+Codable.swift"; sourceTree = "<group>"; };
58561C98239A5D1500BD6B5E /* IPEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPEndpoint.swift; sourceTree = "<group>"; };
5857F24224C8662600CF6F47 /* SelectLocationHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectLocationHeaderView.swift; sourceTree = "<group>"; };
@@ -420,7 +436,6 @@
5871FB95254ADE4E0051A0A4 /* ConsolidatedApplicationLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsolidatedApplicationLog.swift; sourceTree = "<group>"; };
5871FB9F254C26BF0051A0A4 /* NSRegularExpression+IPAddress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSRegularExpression+IPAddress.swift"; sourceTree = "<group>"; };
58727282265D173C00F315B2 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = "<group>"; };
- 5873884C239E6D7E00E96C4E /* EmbeddedViewContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmbeddedViewContainerView.swift; sourceTree = "<group>"; };
587425C02299833500CA2045 /* RootContainerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootContainerViewController.swift; sourceTree = "<group>"; };
5875960626F36B3A00BF6711 /* TunnelIPCError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelIPCError.swift; sourceTree = "<group>"; };
5875960926F371FC00BF6711 /* TunnelIPCSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelIPCSession.swift; sourceTree = "<group>"; };
@@ -438,6 +453,10 @@
587B7544266922BF00DEF7E9 /* en */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
587C575226D2615F005EF767 /* PacketTunnelOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketTunnelOptions.swift; sourceTree = "<group>"; };
587CBFE222807F530028DED3 /* UIColor+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+Helpers.swift"; sourceTree = "<group>"; };
+ 587EB669270EFACB00123C75 /* CharacterSet+IPAddress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CharacterSet+IPAddress.swift"; sourceTree = "<group>"; };
+ 587EB66F27143B6500123C75 /* DataSourceSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataSourceSnapshot.swift; sourceTree = "<group>"; };
+ 587EB671271451E300123C75 /* PreferencesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesViewModel.swift; sourceTree = "<group>"; };
+ 587EB6732714520600123C75 /* PreferencesDataSourceDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesDataSourceDelegate.swift; sourceTree = "<group>"; };
5888AD82227B11080051EB06 /* SelectLocationCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectLocationCell.swift; sourceTree = "<group>"; };
5888AD86227B17950051EB06 /* SelectLocationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectLocationViewController.swift; sourceTree = "<group>"; };
588DD76A26FCB49E006F6233 /* Cancellable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cancellable.swift; sourceTree = "<group>"; };
@@ -449,7 +468,6 @@
5896AE83246D5889005B36CB /* CustomDateComponentsFormatting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDateComponentsFormatting.swift; sourceTree = "<group>"; };
5896AE85246D6AD8005B36CB /* CustomDateComponentsFormattingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDateComponentsFormattingTests.swift; sourceTree = "<group>"; };
5896CEF126972DEB00B0FAE8 /* AccountContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountContentView.swift; sourceTree = "<group>"; };
- 589AB4F6227B64450039131E /* BasicTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicTableViewCell.swift; sourceTree = "<group>"; };
58A1AA8623F43901009F7EA6 /* Location.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Location.swift; sourceTree = "<group>"; };
58A1AA8B23F5584B009F7EA6 /* ConnectionPanelView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConnectionPanelView.swift; sourceTree = "<group>"; };
58A94AE326CFD945001CB97C /* TunnelErrorNotificationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelErrorNotificationProvider.swift; sourceTree = "<group>"; };
@@ -738,12 +756,13 @@
58B0A2A1238EE67E00BC001D /* MullvadVPNTests */ = {
isa = PBXGroup;
children = (
- 58A94AE526D23C3D001CB97C /* PromiseTests.swift */,
582AE3112440CA0D00E6733A /* AccountTokenInputTests.swift */,
+ 5896AE85246D6AD8005B36CB /* CustomDateComponentsFormattingTests.swift */,
58B0A2A4238EE67E00BC001D /* Info.plist */,
+ 58A94AE526D23C3D001CB97C /* PromiseTests.swift */,
584B26F3237434D00073B10E /* RelaySelectorTests.swift */,
5807E2C1243203D000F5FF30 /* StringTests.swift */,
- 5896AE85246D6AD8005B36CB /* CustomDateComponentsFormattingTests.swift */,
+ 5819C2132726CC8D00D6EC38 /* DataSourceSnapshotTests.swift */,
);
path = MullvadVPNTests;
sourceTree = "<group>";
@@ -786,6 +805,7 @@
582AE30F2440A6CA00E6733A /* AccountTokenInput.swift */,
58CCA01722426713004F3011 /* AccountViewController.swift */,
58B9EB122488ED2100095626 /* AlertPresenter.swift */,
+ 584D26BE270C550B004EA533 /* AnyIPAddress.swift */,
5868585424054096000B8131 /* AppButton.swift */,
58CE5E63224146200008646E /* AppDelegate.swift */,
58BFA5CB22A7CE1F00A6173D /* ApplicationConfiguration.swift */,
@@ -793,9 +813,9 @@
58FD5BE624192A2B00112C88 /* AppStoreReceipt.swift */,
58CE5E6A224146210008646E /* Assets.xcassets */,
58FEEB57260B662E00A621A8 /* AutomaticKeyboardResponder.swift */,
- 589AB4F6227B64450039131E /* BasicTableViewCell.swift */,
5891BF1B25E3E3EB006D6FB0 /* Bundle+ProductVersion.swift */,
58F840B12464491D0044E708 /* ChainedError.swift */,
+ 587EB669270EFACB00123C75 /* CharacterSet+IPAddress.swift */,
58A1AA8B23F5584B009F7EA6 /* ConnectionPanelView.swift */,
58B43C1825F77DB60002C8C3 /* ConnectMainContentView.swift */,
58CCA00F224249A1004F3011 /* ConnectViewController.swift */,
@@ -810,9 +830,9 @@
58ACF64E26567A7100ACE4B7 /* CustomSwitchContainer.swift */,
58293FB025124117005D0BB5 /* CustomTextField.swift */,
58293FB2251241B3005D0BB5 /* CustomTextView.swift */,
+ 587EB66F27143B6500123C75 /* DataSourceSnapshot.swift */,
58907D9424D17B4E00CFC3F5 /* DisconnectSplitButton.swift */,
58B9EB142489139B00095626 /* DisplayChainedError.swift */,
- 5873884C239E6D7E00E96C4E /* EmbeddedViewContainerView.swift */,
5892A45D265FABFF00890742 /* EmptyTableViewHeaderFooterView.swift */,
58FEEB45260A028D00A621A8 /* GeoJSON.swift */,
58F3C0A3249CB069003E76BE /* HeaderBarView.swift */,
@@ -841,7 +861,10 @@
5871FB9F254C26BF0051A0A4 /* NSRegularExpression+IPAddress.swift */,
58CC40EE24A601900019D96E /* ObserverList.swift */,
580EE1FF24B3218800F9D8A1 /* Operations */,
+ 584D26C3270C855A004EA533 /* PreferencesDataSource.swift */,
+ 587EB6732714520600123C75 /* PreferencesDataSourceDelegate.swift */,
58ACF6482655365700ACE4B7 /* PreferencesViewController.swift */,
+ 587EB671271451E300123C75 /* PreferencesViewModel.swift */,
58C6B35322BB87C4003C19AD /* PrivateKeyWithMetadata.swift */,
58F8AC0D25D3F8CE002BE0ED /* ProblemReportReviewViewController.swift */,
58EF580A25D69D7A00AEBA94 /* ProblemReportSubmissionOverlayView.swift */,
@@ -859,7 +882,9 @@
5888AD86227B17950051EB06 /* SelectLocationViewController.swift */,
582BB1B2229574F40055B6EF /* SettingsAccountCell.swift */,
582BB1AE229566420055B6EF /* SettingsCell.swift */,
+ 584D26C5270C8741004EA533 /* SettingsDNSTextCell.swift */,
58E6771E24ADFE7800AA26E7 /* SettingsNavigationController.swift */,
+ 584D26C1270C8542004EA533 /* SettingsStaticTextFooterView.swift */,
58ACF64A26553C3F00ACE4B7 /* SettingsSwitchCell.swift */,
58CCA01122424D11004F3011 /* SettingsViewController.swift */,
58BA693023EADA6A009DC256 /* SimulatorTunnelProvider.swift */,
@@ -883,6 +908,7 @@
58B8743122B25A7600015324 /* WireguardAssociatedAddresses.swift */,
58F7CA872692E34000FC59FD /* WireguardKeysContentView.swift */,
5877152F23981F7B001F8237 /* WireguardKeysViewController.swift */,
+ 5819C2162729595500D6EC38 /* SettingsAddDNSEntryCell.swift */,
);
path = MullvadVPN;
sourceTree = "<group>";
@@ -1176,7 +1202,6 @@
buildActionMask = 2147483647;
files = (
58F3C0A724A50C02003E76BE /* relays.json in Resources */,
- 584789BF264D4A2A000E45FB /* le_root_cert.cer in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -1206,6 +1231,7 @@
5896AE82246ACE84005B36CB /* KeychainReturn.swift in Sources */,
58B0A2A8238EE68200BC001D /* RelaySelectorTests.swift in Sources */,
5806766D27048E5500C858CB /* KeychainMatchLimit.swift in Sources */,
+ 5819C2152726CC9400D6EC38 /* DataSourceSnapshot.swift in Sources */,
584E96BE240FD4DB00D3334F /* Location.swift in Sources */,
5857F23F24C844AD00CF6F47 /* Locking.swift in Sources */,
5857F23424C8443700CF6F47 /* AsyncOperation.swift in Sources */,
@@ -1228,8 +1254,10 @@
58B0A2A9238EE6A100BC001D /* RelayConstraints.swift in Sources */,
5807E2C2243203D000F5FF30 /* StringTests.swift in Sources */,
5806766B27048E3C00C858CB /* AnyOptional.swift in Sources */,
+ 5819C2142726CC8D00D6EC38 /* DataSourceSnapshotTests.swift in Sources */,
585DA8A326B14E0D00B8C587 /* ServerRelaysResponse.swift in Sources */,
5820676226E75D8500655B05 /* REST.swift in Sources */,
+ 58A8055E2716EA6700681642 /* AnyIPAddress.swift in Sources */,
5857F23024C843ED00CF6F47 /* ChainedError.swift in Sources */,
58A8BE81239FBE62006B74AC /* IPEndpoint.swift in Sources */,
5846227A26E24F1F0035F7C2 /* ExclusivityController.swift in Sources */,
@@ -1255,6 +1283,8 @@
5806767027048E6A00C858CB /* Promise.swift in Sources */,
5820675B26E6576800655B05 /* RelayCache.swift in Sources */,
5846226526E0D9630035F7C2 /* ProductsRequestOperation.swift in Sources */,
+ 587EB672271451E300123C75 /* PreferencesViewModel.swift in Sources */,
+ 584D26C6270C8741004EA533 /* SettingsDNSTextCell.swift in Sources */,
585DA87D26B0254000B8C587 /* RelayCacheIO.swift in Sources */,
58BA693123EADA6A009DC256 /* SimulatorTunnelProvider.swift in Sources */,
587C575326D2615F005EF767 /* PacketTunnelOptions.swift in Sources */,
@@ -1278,6 +1308,7 @@
588D2FE3248AC27F00E313F7 /* AsyncOperation.swift in Sources */,
5820675026E6514100655B05 /* HTTP.swift in Sources */,
585DA89126B0322700B8C587 /* TunnelIPC.swift in Sources */,
+ 584D26C2270C8542004EA533 /* SettingsStaticTextFooterView.swift in Sources */,
5877153023981F7B001F8237 /* WireguardKeysViewController.swift in Sources */,
587B7536266528A200DEF7E9 /* NotificationManager.swift in Sources */,
58FB865A26EA214400F188BC /* RelayCacheObserver.swift in Sources */,
@@ -1330,14 +1361,17 @@
58B67B482602079E008EF58E /* RelaySelector.swift in Sources */,
58DF28A52417CB4B00E836B0 /* AppStorePaymentManager.swift in Sources */,
583DA21425FA4B5C00318683 /* LocationDataSource.swift in Sources */,
+ 587EB6742714520600123C75 /* PreferencesDataSourceDelegate.swift in Sources */,
5878BA1426DD0B01004147D7 /* OSLogHandler.swift in Sources */,
582BB1AF229566420055B6EF /* SettingsCell.swift in Sources */,
- 5873884D239E6D7E00E96C4E /* EmbeddedViewContainerView.swift in Sources */,
58F3C0A4249CB069003E76BE /* HeaderBarView.swift in Sources */,
5820674926E63EC900655B05 /* Promise+BackgroundTask.swift in Sources */,
58B9EB132488ED2100095626 /* AlertPresenter.swift in Sources */,
587A01FC23F1F0BE00B68763 /* SimulatorTunnelProviderHost.swift in Sources */,
+ 5819C2172729595500D6EC38 /* SettingsAddDNSEntryCell.swift in Sources */,
+ 584D26BF270C550B004EA533 /* AnyIPAddress.swift in Sources */,
5862805422428EF100F5A6E1 /* TranslucentButtonBlurView.swift in Sources */,
+ 587EB66A270EFACB00123C75 /* CharacterSet+IPAddress.swift in Sources */,
580EE20624B3222200F9D8A1 /* ExclusivityController.swift in Sources */,
5888AD83227B11080051EB06 /* SelectLocationCell.swift in Sources */,
585DA89326B0323E00B8C587 /* TunnelIPCRequest.swift in Sources */,
@@ -1368,7 +1402,6 @@
58FD5BF42428C67600112C88 /* InAppPurchaseButton.swift in Sources */,
580EE22424B3243100F9D8A1 /* AsyncBlockOperation.swift in Sources */,
58293FAE2510CA58005D0BB5 /* ProblemReportViewController.swift in Sources */,
- 589AB4F7227B64450039131E /* BasicTableViewCell.swift in Sources */,
58B9EB152489139B00095626 /* DisplayChainedError.swift in Sources */,
587B753F2668E5A700DEF7E9 /* NotificationContainerView.swift in Sources */,
5868BD33261DCD2600E6027F /* CustomSplitViewController.swift in Sources */,
@@ -1388,6 +1421,7 @@
585DA89626B0328000B8C587 /* TunnelIPCResponse.swift in Sources */,
587AD7C623421D7000E93A53 /* TunnelSettings.swift in Sources */,
581503A324D6F1EC00C9C50E /* ChainedError+Logger.swift in Sources */,
+ 584D26C4270C855B004EA533 /* PreferencesDataSource.swift in Sources */,
58FD5BF024238EB300112C88 /* SKProduct+Formatting.swift in Sources */,
58B43C1925F77DB60002C8C3 /* ConnectMainContentView.swift in Sources */,
58561C99239A5D1500BD6B5E /* IPEndpoint.swift in Sources */,
@@ -1398,6 +1432,7 @@
58FAEDFF24533A7000CB0F5B /* KeychainReturn.swift in Sources */,
58ACF64B26553C3F00ACE4B7 /* SettingsSwitchCell.swift in Sources */,
5806767F27048EC000C858CB /* Promise+ReceiveOn.swift in Sources */,
+ 587EB67027143B6500123C75 /* DataSourceSnapshot.swift in Sources */,
585DA88A26B027A300B8C587 /* RESTCoding.swift in Sources */,
587B753D2666468F00DEF7E9 /* NotificationController.swift in Sources */,
);
@@ -1421,6 +1456,7 @@
58BFA5CD22A7CE1F00A6173D /* ApplicationConfiguration.swift in Sources */,
5850368D25A49E2200A43E93 /* PrivateKeyWithMetadata.swift in Sources */,
5820675826E652AF00655B05 /* RelayCacheIO.swift in Sources */,
+ 584D26C0270C550E004EA533 /* AnyIPAddress.swift in Sources */,
5820675726E652A600655B05 /* REST.swift in Sources */,
585DA88F26B031E200B8C587 /* TunnelIPCCoding.swift in Sources */,
5806767C27048E9B00C858CB /* PacketTunnelProvider.swift in Sources */,
diff --git a/ios/MullvadVPN/AnyIPAddress.swift b/ios/MullvadVPN/AnyIPAddress.swift
new file mode 100644
index 0000000000..1898754a69
--- /dev/null
+++ b/ios/MullvadVPN/AnyIPAddress.swift
@@ -0,0 +1,101 @@
+//
+// AnyIPAddress.swift
+// MullvadVPN
+//
+// Created by pronebird on 05/10/2021.
+// Copyright © 2021 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+import Network
+
+/// Container type that holds either `IPv4Address` or `IPv6Address`.
+enum AnyIPAddress: IPAddress, Codable, Equatable, CustomDebugStringConvertible {
+ case ipv4(IPv4Address)
+ case ipv6(IPv6Address)
+
+ private enum CodingKeys: String, CodingKey {
+ case ipv4, ipv6
+ }
+
+ private var innerAddress: IPAddress {
+ switch self {
+ case .ipv4(let ipv4Address):
+ return ipv4Address
+ case .ipv6(let ipv6Address):
+ return ipv6Address
+ }
+ }
+
+ var rawValue: Data {
+ return innerAddress.rawValue
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+
+ if container.contains(.ipv4) {
+ self = .ipv4(try container.decode(IPv4Address.self, forKey: .ipv4))
+ } else if container.contains(.ipv6) {
+ self = .ipv6(try container.decode(IPv6Address.self, forKey: .ipv6))
+ } else {
+ throw DecodingError.dataCorruptedError(forKey: .ipv4, in: container, debugDescription: "Invalid AnyIPAddress representation")
+ }
+ }
+
+ func encode(to encoder: Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+
+ switch self {
+ case .ipv4(let ipv4Address):
+ try container.encode(ipv4Address, forKey: .ipv4)
+ case .ipv6(let ipv6Address):
+ try container.encode(ipv6Address, forKey: .ipv6)
+ }
+ }
+
+ init?(_ rawValue: Data, _ interface: NWInterface?) {
+ if let ipv4Address = IPv4Address(rawValue, interface) {
+ self = .ipv4(ipv4Address)
+ } else if let ipv6Address = IPv6Address(rawValue, interface) {
+ self = .ipv6(ipv6Address)
+ } else {
+ return nil
+ }
+ }
+
+ init?(_ string: String) {
+ if let ipv4Address = IPv4Address(string) {
+ self = .ipv4(ipv4Address)
+ } else if let ipv6Address = IPv6Address(string) {
+ self = .ipv6(ipv6Address)
+ } else {
+ return nil
+ }
+ }
+
+ var interface: NWInterface? {
+ return innerAddress.interface
+ }
+
+ var isLoopback: Bool {
+ return innerAddress.isLoopback
+ }
+
+ var isLinkLocal: Bool {
+ return innerAddress.isLinkLocal
+ }
+
+ var isMulticast: Bool {
+ return innerAddress.isMulticast
+ }
+
+ var debugDescription: String {
+ switch self {
+ case .ipv4(let ipv4Address):
+ return "\(ipv4Address)"
+ case .ipv6(let ipv6Address):
+ return "\(ipv6Address)"
+ }
+ }
+}
diff --git a/ios/MullvadVPN/BasicTableViewCell.swift b/ios/MullvadVPN/BasicTableViewCell.swift
deleted file mode 100644
index fd506d309d..0000000000
--- a/ios/MullvadVPN/BasicTableViewCell.swift
+++ /dev/null
@@ -1,32 +0,0 @@
-//
-// BasicTableViewCell.swift
-// MullvadVPN
-//
-// Created by pronebird on 02/05/2019.
-// Copyright © 2019 Mullvad VPN AB. All rights reserved.
-//
-
-import UIKit
-
-class BasicTableViewCell: UITableViewCell {
-
- override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
- super.init(style: style, reuseIdentifier: reuseIdentifier)
-
- let backgroundView = UIView()
- backgroundView.backgroundColor = UIColor.Cell.backgroundColor
-
- let selectedBackgroundView = UIView()
- selectedBackgroundView.backgroundColor = UIColor.Cell.selectedBackgroundColor
-
- self.backgroundView = backgroundView
- self.selectedBackgroundView = selectedBackgroundView
- backgroundColor = UIColor.clear
- contentView.backgroundColor = UIColor.clear
- }
-
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
-
-}
diff --git a/ios/MullvadVPN/CharacterSet+IPAddress.swift b/ios/MullvadVPN/CharacterSet+IPAddress.swift
new file mode 100644
index 0000000000..f15d134f84
--- /dev/null
+++ b/ios/MullvadVPN/CharacterSet+IPAddress.swift
@@ -0,0 +1,20 @@
+//
+// CharacterSet+IPAddress.swift
+// MullvadVPN
+//
+// Created by pronebird on 07/10/2021.
+// Copyright © 2021 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+
+extension CharacterSet {
+ static var ipv4AddressCharset: CharacterSet {
+ return CharacterSet(charactersIn: "0123456789.")
+ }
+
+ static var ipv6AddressCharset: CharacterSet {
+ return CharacterSet(charactersIn: "0123456789abcdef:.")
+ }
+
+}
diff --git a/ios/MullvadVPN/CustomSwitch.swift b/ios/MullvadVPN/CustomSwitch.swift
index b804dae7ae..d0eb29aeca 100644
--- a/ios/MullvadVPN/CustomSwitch.swift
+++ b/ios/MullvadVPN/CustomSwitch.swift
@@ -28,8 +28,12 @@ class CustomSwitch: UISwitch {
override init(frame: CGRect) {
super.init(frame: frame)
- self.tintColor = .clear
- self.onTintColor = .clear
+ tintColor = .clear
+ onTintColor = .clear
+
+ if #available(iOS 13.0, *) {
+ overrideUserInterfaceStyle = .light
+ }
updateThumbColor(isOn: self.isOn, animated: false)
@@ -61,7 +65,7 @@ class CustomSwitch: UISwitch {
@objc private func valueChanged(_ sender: Any) {
if #available(iOS 13, *) {
- self.updateThumbColor(isOn: self.isOn, animated: true)
+ updateThumbColor(isOn: isOn, animated: true)
} else {
// Wait for animations to finish before changing the thumb color to prevent the jumpy behaviour.
CATransaction.setCompletionBlock {
diff --git a/ios/MullvadVPN/CustomSwitchContainer.swift b/ios/MullvadVPN/CustomSwitchContainer.swift
index ebdcc33b13..7acafc4495 100644
--- a/ios/MullvadVPN/CustomSwitchContainer.swift
+++ b/ios/MullvadVPN/CustomSwitchContainer.swift
@@ -23,6 +23,16 @@ class CustomSwitchContainer: UIView {
let control = CustomSwitch()
+ var isEnabled: Bool {
+ get {
+ return control.isEnabled
+ }
+ set {
+ control.isEnabled = newValue
+ updateBorderOpacity()
+ }
+ }
+
override var intrinsicContentSize: CGSize {
return controlSize()
}
@@ -42,6 +52,8 @@ class CustomSwitchContainer: UIView {
borderShape.cornerRadius = self.bounds.height * 0.5
borderShape.frame = self.bounds
+
+ updateBorderOpacity()
}
required init?(coder: NSCoder) {
@@ -63,4 +75,13 @@ class CustomSwitchContainer: UIView {
return size
}
+ private func updateBorderOpacity() {
+ CATransaction.begin()
+ CATransaction.setDisableActions(true)
+
+ borderShape.opacity = control.isEnabled ? 1 : 0.2
+
+ CATransaction.commit()
+ }
+
}
diff --git a/ios/MullvadVPN/CustomTextField.swift b/ios/MullvadVPN/CustomTextField.swift
index 378dde07aa..c923d3e925 100644
--- a/ios/MullvadVPN/CustomTextField.swift
+++ b/ios/MullvadVPN/CustomTextField.swift
@@ -9,10 +9,20 @@
import Foundation
import UIKit
-private let kTextFieldCornerRadius = CGFloat(4)
-
class CustomTextField: UITextField {
+ var cornerRadius: CGFloat = 4 {
+ didSet {
+ layer.cornerRadius = cornerRadius
+ }
+ }
+
+ var textMargins = UIEdgeInsets(top: 12, left: 14, bottom: 12, right: 14) {
+ didSet {
+ setNeedsLayout()
+ }
+ }
+
var placeholderTextColor: UIColor = UIColor.TextField.placeholderTextColor {
didSet {
updatePlaceholderTextColor()
@@ -29,7 +39,7 @@ class CustomTextField: UITextField {
super.init(frame: frame)
textColor = UIColor.TextField.textColor
- layer.cornerRadius = kTextFieldCornerRadius
+ layer.cornerRadius = cornerRadius
clipsToBounds = true
}
@@ -48,7 +58,7 @@ class CustomTextField: UITextField {
}
override func textRect(forBounds bounds: CGRect) -> CGRect {
- return bounds.insetBy(dx: 14, dy: 12)
+ return bounds.inset(by: textMargins)
}
override func editingRect(forBounds bounds: CGRect) -> CGRect {
diff --git a/ios/MullvadVPN/DataSourceSnapshot.swift b/ios/MullvadVPN/DataSourceSnapshot.swift
new file mode 100644
index 0000000000..cb97945b65
--- /dev/null
+++ b/ios/MullvadVPN/DataSourceSnapshot.swift
@@ -0,0 +1,447 @@
+//
+// DataSourceSnapshot.swift
+// MullvadVPN
+//
+// Created by pronebird on 11/10/2021.
+// Copyright © 2021 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+import UIKit
+
+/// `NSDiffableDataSourceSnapshot` replica.
+struct DataSourceSnapshot<Section: Hashable, Item: Hashable> {
+ /// Ordered set of section identifiers.
+ private var orderedSections = NSMutableOrderedSet()
+
+ /// Ordered set of item identifiers.
+ private var orderedItems = NSMutableOrderedSet()
+
+ /// Item identifier ranges by section.
+ private var sectionToItemMapping = [Range<Int>]()
+
+ /// Items to reload.
+ private var itemsToReload = NSMutableOrderedSet()
+
+ /// Items to reconfigure.
+ private var itemsToReconfigure = NSMutableOrderedSet()
+
+ /// Ordered array of section identifiers.
+ var sectionIdentifiers: [Section] {
+ return orderedSections.array as! [Section]
+ }
+
+ /// Ordered array of item identifiers.
+ var itemIdentifiers: [Item] {
+ return orderedItems.array as! [Item]
+ }
+
+ mutating func appendItems(_ itemsToAppend: [Item], in section: Section) {
+ assert(orderedSections.contains(section))
+
+ let sectionIndex = indexOfSection(section)!
+ let uniqueItemsToAppend = NSOrderedSet(array: itemsToAppend)
+ let itemRange = sectionToItemMapping[sectionIndex]
+
+ let oldEndIndex = itemRange.endIndex
+ let newEndIndex = itemRange.endIndex.advanced(by: uniqueItemsToAppend.count)
+ let newItemRange = (itemRange.startIndex ..< newEndIndex)
+
+ sectionToItemMapping[sectionIndex] = newItemRange
+ orderedItems.insert(uniqueItemsToAppend.array, at: IndexSet(integersIn: oldEndIndex..<newEndIndex))
+
+ offsetItemRange(inSectionsAfter: sectionIndex, by: uniqueItemsToAppend.count)
+ }
+
+ mutating func appendSections(_ newSections: [Section]) {
+ let lastSectionRange = sectionToItemMapping.last
+ let emptyRange = lastSectionRange.flatMap { range in
+ return (range.upperBound..<range.upperBound)
+ } ?? (0..<0)
+
+ let uniqueNewSections = NSOrderedSet(array: newSections)
+
+ for newSection in uniqueNewSections {
+ orderedSections.add(newSection)
+ sectionToItemMapping.append(emptyRange)
+ }
+ }
+
+ func section(at index: Int) -> Section? {
+ if index < orderedSections.count {
+ return orderedSections.object(at: index) as? Section
+ } else {
+ return nil
+ }
+ }
+
+ func indexOfSection(_ section: Section) -> Int? {
+ let index = orderedSections.index(of: section)
+ if index == NSNotFound {
+ return nil
+ } else {
+ return index
+ }
+ }
+
+ func numberOfSections() -> Int {
+ return orderedSections.count
+ }
+
+ func numberOfItems(in section: Section) -> Int? {
+ guard let sectionIndex = indexOfSection(section) else { return nil }
+
+ return sectionToItemMapping[sectionIndex].count
+ }
+
+ func items(in section: Section) -> [Item] {
+ guard let sectionIndex = indexOfSection(section) else { return [] }
+
+ let range = sectionToItemMapping[sectionIndex]
+ let indexSet = IndexSet(integersIn: range)
+
+ return orderedItems.objects(at: indexSet) as! [Item]
+ }
+
+ func itemForIndexPath(_ indexPath: IndexPath) -> Item? {
+ guard indexPath.section < orderedSections.count else { return nil }
+
+ let itemRange = sectionToItemMapping[indexPath.section]
+ let itemIndex = itemRange.startIndex + indexPath.row
+
+ if itemRange.contains(itemIndex) {
+ return orderedItems.object(at: itemIndex) as? Item
+ } else {
+ return nil
+ }
+ }
+
+ func indexPathForItem(_ item: Item) -> IndexPath? {
+ let itemIndex = orderedItems.index(of: item)
+ guard itemIndex != NSNotFound else { return nil }
+
+ guard let sectionIdentifier = section(containingItem: item) else { return nil }
+
+ let sectionIndex = orderedSections.index(of: sectionIdentifier)
+ guard sectionIndex != NSNotFound else { return nil }
+
+ let range = sectionToItemMapping[sectionIndex]
+ let rowIndex = itemIndex - range.startIndex
+
+ return IndexPath(row: rowIndex, section: sectionIndex)
+ }
+
+ func section(containingItem item: Item) -> Section? {
+ let itemIndex = orderedItems.index(of: item)
+ guard itemIndex != NSNotFound else { return nil }
+
+ for (sectionIndex, sectionObject) in orderedSections.enumerated() {
+ let sectionIdentifier = sectionObject as! Section
+ let range = sectionToItemMapping[sectionIndex]
+
+ if range.contains(itemIndex) {
+ return sectionIdentifier
+ }
+ }
+
+ return nil
+ }
+
+ mutating func reloadItems(_ items: [Item]) {
+ itemsToReload.addObjects(from: items)
+ }
+
+ mutating func reconfigureItems(_ items: [Item]) {
+ itemsToReconfigure.addObjects(from: items)
+ }
+
+ private mutating func offsetItemRange(inSectionsAfter sectionIndex: Int, by offset: Int) {
+ let startIndex = sectionIndex + 1
+ let sectionRange = (startIndex ..< orderedSections.count)
+
+ for sectionIndex in sectionRange {
+ let range = sectionToItemMapping[sectionIndex]
+ let offsetRange = (range.startIndex + offset ..< range.endIndex + offset)
+
+ sectionToItemMapping[sectionIndex] = offsetRange
+ }
+ }
+}
+
+extension DataSourceSnapshot {
+ enum Change: CustomDebugStringConvertible, Hashable {
+ case insert(IndexPath)
+ case delete(IndexPath)
+ case move(_ source: IndexPath, _ target: IndexPath)
+ case reload(IndexPath)
+ case reconfigure(IndexPath)
+
+ var sortOrder: Int {
+ switch self {
+ case .delete:
+ return 0
+ case .insert:
+ return 1
+ case .move:
+ return 2
+ case .reload:
+ return 3
+ case .reconfigure:
+ return 4
+ }
+ }
+
+ var debugDescription: String {
+ switch self {
+ case .insert(let indexPath):
+ return "insert \(indexPath)"
+ case .delete(let indexPath):
+ return "delete \(indexPath)"
+ case .move(let source, let target):
+ return "move from \(source) to \(target)"
+ case .reload(let indexPath):
+ return "reload \(indexPath)"
+ case .reconfigure(let indexPath):
+ return "reconfigure \(indexPath)"
+ }
+ }
+
+ func breakMoveOntoInsertionDeletion() -> [Change] {
+ if case .move(let fromIndexPath, let toIndexPath) = self {
+ return [.delete(fromIndexPath), .insert(toIndexPath)]
+ } else {
+ return [self]
+ }
+ }
+ }
+
+ func difference(_ other: DataSourceSnapshot<Section, Item>) -> DataSnapshotDifference {
+ var changes = [Change]()
+
+ let oldItems = itemIdentifiers
+ let newItems = other.itemIdentifiers
+
+ for item in oldItems {
+ let oldIndexPath = indexPathForItem(item)
+ let newIndexPath = other.indexPathForItem(item)
+
+ if let oldIndexPath = oldIndexPath, oldIndexPath != newIndexPath {
+ guard let newIndexPath = newIndexPath else {
+ changes.append(.delete(oldIndexPath))
+ continue
+ }
+
+ // Guard against recording the `.move` twice when exchanging two adjacent items.
+ let isSwappingTwoAdjacentItems = changes.contains { otherChange in
+ if case .move(let fromIndexPath, let toIndexPath) = otherChange {
+ let itemDistance = abs(oldIndexPath.row - fromIndexPath.row)
+
+ return oldIndexPath == toIndexPath && newIndexPath == fromIndexPath &&
+ oldIndexPath.section == newIndexPath.section &&
+ itemDistance == 1
+
+ } else {
+ return false
+ }
+ }
+
+ if !isSwappingTwoAdjacentItems {
+ changes.append(.move(oldIndexPath, newIndexPath))
+ }
+ }
+ }
+
+ for item in newItems {
+ if let indexPath = other.indexPathForItem(item), !oldItems.contains(item) {
+ changes.append(.insert(indexPath))
+ }
+ }
+
+ changes = Self.inferMoves(changes: changes)
+
+ for itemObject in other.itemsToReload {
+ let itemIdentifier = itemObject as! Item
+ if let indexPath = other.indexPathForItem(itemIdentifier) {
+ changes.append(.reload(indexPath))
+ }
+ }
+
+ for itemObject in other.itemsToReconfigure {
+ let itemIdentifier = itemObject as! Item
+ if let indexPath = other.indexPathForItem(itemIdentifier) {
+ changes.append(.reconfigure(indexPath))
+ }
+ }
+
+ changes.sort(by: Self.changeSortPredicate)
+
+ return Self.changeSetToDifference(changes)
+ }
+
+ /// Infer and discard unnecessary moves that occur due to items shifting back or forth based on insertions and
+ /// deletions of other items.
+ private static func inferMoves(changes: [Change]) -> [Change] {
+ var newChanges = [Change]()
+
+ // Expand .move onto .insert + .delete pair and sort changes.
+ let sortedChangesWithoutMoves = changes
+ .flatMap { change in
+ return change.breakMoveOntoInsertionDeletion()
+ }
+ .sorted(by: Self.changeSortPredicate)
+
+ for sourceChange in changes {
+ guard case .move(let sourceIndexPath, let targetIndexPath) = sourceChange else {
+ newChanges.append(sourceChange)
+ continue
+ }
+
+ // Replay all changes to compute the item's index path, ignoring the changes associated with the current
+ // change.
+ let inferredIndexPath = sortedChangesWithoutMoves.reduce(into: sourceIndexPath) { inferredIndexPath, otherChange in
+ switch otherChange {
+ case .insert(let insertedIndexPath) where insertedIndexPath != targetIndexPath:
+ if inferredIndexPath.row >= insertedIndexPath.row, inferredIndexPath.section == insertedIndexPath.section {
+ inferredIndexPath.row += 1
+ }
+
+ case .delete(let deletedIndexPath) where deletedIndexPath != sourceIndexPath:
+ if inferredIndexPath.row > deletedIndexPath.row, inferredIndexPath.section == deletedIndexPath.section {
+ inferredIndexPath.row -= 1
+ }
+
+ default:
+ break
+ }
+ }
+
+ // Discard the change if the index path, produced after replaying other changes, matches the target index
+ // path.
+ if inferredIndexPath != targetIndexPath {
+ newChanges.append(contentsOf: sourceChange.breakMoveOntoInsertionDeletion())
+ }
+ }
+
+ return newChanges
+ }
+
+ /// Sort predicate used for sorting a collection of `Change`.
+ ///
+ /// Sort order by kind and index path:
+ /// Deletion: descending
+ /// Insertion: ascending
+ /// Reload, reconfigure: ascending
+ private static func changeSortPredicate(_ lhs: Change, _ rhs: Change) -> Bool {
+ switch (lhs, rhs) {
+ case (.insert(let lhsIndexPath), .insert(let rhsIndexPath)):
+ return lhsIndexPath < rhsIndexPath
+
+ case (.delete(let lhsIndexPath), .delete(let rhsIndexPath)):
+ return lhsIndexPath > rhsIndexPath
+
+ case (.reload(let lhsIndexPath), .reload(let rhsIndexPath)):
+ return lhsIndexPath < rhsIndexPath
+
+ case (.reconfigure(let lhsIndexPath), .reconfigure(let rhsIndexPath)):
+ return lhsIndexPath < rhsIndexPath
+
+ case (let lhs, let rhs):
+ return lhs.sortOrder < rhs.sortOrder
+ }
+ }
+
+ private static func changeSetToDifference(_ changes: [Change]) -> DataSnapshotDifference {
+ var indexPathsToInsert = [IndexPath]()
+ var indexPathsToDelete = [IndexPath]()
+ var indexPathsToReload = [IndexPath]()
+ var indexPathsToReconfigure = [IndexPath]()
+
+ for change in changes {
+ switch change {
+ case .insert(let indexPath):
+ indexPathsToInsert.append(indexPath)
+
+ case .delete(let indexPath):
+ indexPathsToDelete.append(indexPath)
+
+ case .move:
+ // Moves are broken down onto insert and delete changes at this point.
+ break
+
+ case .reload(let indexPath):
+ indexPathsToReload.append(indexPath)
+
+ case .reconfigure(let indexPath):
+ indexPathsToReconfigure.append(indexPath)
+ }
+ }
+
+ return DataSnapshotDifference(
+ indexPathsToInsert: indexPathsToInsert,
+ indexPathsToDelete: indexPathsToDelete,
+ indexPathsToReload: indexPathsToReload,
+ indexPathsToReconfigure: indexPathsToReconfigure
+ )
+ }
+}
+
+struct DataSnapshotDifference: CustomDebugStringConvertible {
+ var indexPathsToInsert = [IndexPath]()
+ var indexPathsToDelete = [IndexPath]()
+ var indexPathsToReload = [IndexPath]()
+ var indexPathsToReconfigure = [IndexPath]()
+
+ var debugDescription: String {
+ var s = "DataSnapshotDifference {\n"
+
+ s += " insert: \n"
+ for indexPath in indexPathsToInsert {
+ s += " \(indexPath),\n"
+ }
+
+ s += " delete: \n"
+ for indexPath in indexPathsToDelete {
+ s += " \(indexPath),\n"
+ }
+
+ s += " reload: \n"
+ for indexPath in indexPathsToReload {
+ s += " \(indexPath),\n"
+ }
+
+ s += " reconfigure: \n"
+ for indexPath in indexPathsToReconfigure {
+ s += " \(indexPath),\n"
+ }
+
+ s += "}"
+
+ return s
+ }
+
+ func apply(to tableView: UITableView, animateDifferences: Bool, completion: ((Bool) -> Void)? = nil) {
+ let animation: UITableView.RowAnimation = animateDifferences ? .automatic : .none
+
+ tableView.performBatchUpdates({
+ if !indexPathsToDelete.isEmpty {
+ tableView.deleteRows(at: indexPathsToDelete, with: animation)
+ }
+
+ if !indexPathsToInsert.isEmpty {
+ tableView.insertRows(at: indexPathsToInsert, with: animation)
+ }
+
+ if !indexPathsToReload.isEmpty {
+ tableView.reloadRows(at: indexPathsToReload, with: animation)
+ }
+
+ if !indexPathsToReconfigure.isEmpty {
+ if #available(iOS 15.0, *) {
+ tableView.reconfigureRows(at: indexPathsToReconfigure)
+ } else {
+ tableView.reloadRows(at: indexPathsToReconfigure, with: .none)
+ }
+ }
+ }, completion: completion)
+ }
+}
diff --git a/ios/MullvadVPN/EmbeddedViewContainerView.swift b/ios/MullvadVPN/EmbeddedViewContainerView.swift
deleted file mode 100644
index 9bb8fe1d2b..0000000000
--- a/ios/MullvadVPN/EmbeddedViewContainerView.swift
+++ /dev/null
@@ -1,60 +0,0 @@
-//
-// EmbeddedViewContainerView.swift
-// MullvadVPN
-//
-// Created by pronebird on 09/12/2019.
-// Copyright © 2019 Mullvad VPN AB. All rights reserved.
-//
-
-import Foundation
-import UIKit
-
-/// A `UIView` subclass that implements a host view for an embedded subview via an outlet.
-@IBDesignable class EmbeddedViewContainerView: UIView {
- @IBOutlet var embeddedView: UIView!
-
- override func awakeFromNib() {
- super.awakeFromNib()
-
- backgroundColor = .clear
-
- embeddedView.translatesAutoresizingMaskIntoConstraints = false
-
- addSubview(embeddedView)
-
- NSLayoutConstraint.activate([
- embeddedView.topAnchor.constraint(equalTo: topAnchor),
- embeddedView.leadingAnchor.constraint(equalTo: leadingAnchor),
- embeddedView.trailingAnchor.constraint(equalTo: trailingAnchor),
- embeddedView.bottomAnchor.constraint(equalTo: bottomAnchor)
- ])
- }
-
- #if TARGET_INTERFACE_BUILDER
- override func draw(_ rect: CGRect) {
- UIColor.white.withAlphaComponent(0.3).setFill()
- UIColor.white.withAlphaComponent(0.6).setStroke()
-
- let bezierPath = UIBezierPath(rect: rect)
- bezierPath.lineWidth = 1
- bezierPath.fill()
- bezierPath.stroke()
-
- let attributedString = NSAttributedString(
- string: "UIView",
- attributes: [.foregroundColor: UIColor.white]
- )
-
- let textSize = attributedString.size()
-
- var textRect = rect
- textRect.origin.x = (rect.width - textSize.width) * 0.5
- textRect.origin.y = (rect.height - textSize.height) * 0.5
- textRect.size = textSize
-
- attributedString.draw(in: textRect)
- }
- #endif
-
-
-}
diff --git a/ios/MullvadVPN/PreferencesDataSource.swift b/ios/MullvadVPN/PreferencesDataSource.swift
new file mode 100644
index 0000000000..a89c1bc3e8
--- /dev/null
+++ b/ios/MullvadVPN/PreferencesDataSource.swift
@@ -0,0 +1,594 @@
+//
+// PreferencesDataSource.swift
+// MullvadVPN
+//
+// Created by pronebird on 05/10/2021.
+// Copyright © 2021 Mullvad VPN AB. All rights reserved.
+//
+
+import UIKit
+
+class PreferencesDataSource: NSObject, UITableViewDataSource, UITableViewDelegate {
+ private enum CellReuseIdentifiers: String, CaseIterable {
+ case settingSwitch
+ case dnsServer
+ case addDNSServer
+
+ var reusableViewClass: AnyClass {
+ switch self {
+ case .settingSwitch:
+ return SettingsSwitchCell.self
+ case .dnsServer:
+ return SettingsDNSTextCell.self
+ case .addDNSServer:
+ return SettingsAddDNSEntryCell.self
+ }
+ }
+ }
+
+ private enum HeaderFooterReuseIdentifiers: String, CaseIterable {
+ case customDNSFooter
+ case spacer
+
+ var reusableViewClass: AnyClass {
+ switch self {
+ case .customDNSFooter:
+ return SettingsStaticTextFooterView.self
+ case .spacer:
+ return EmptyTableViewHeaderFooterView.self
+ }
+ }
+ }
+
+ private enum Section: Hashable {
+ case mullvadDNS
+ case customDNS
+ }
+
+ private enum Item: Hashable {
+ case blockAdvertising
+ case blockTracking
+ case useCustomDNS
+ case addDNSServer
+ case dnsServer(_ uniqueID: UUID)
+
+ static func isDNSServerItem(_ item: Item) -> Bool {
+ if case .dnsServer = item {
+ return true
+ } else {
+ return false
+ }
+ }
+ }
+
+ private var isEditing = false
+ private var snapshot = DataSourceSnapshot<Section, Item>()
+
+ private(set) var viewModel = PreferencesViewModel()
+ private(set) var viewModelBeforeEditing = PreferencesViewModel()
+
+ weak var delegate: PreferencesDataSourceDelegate?
+
+ weak var tableView: UITableView? {
+ didSet {
+ tableView?.dataSource = self
+ tableView?.delegate = self
+
+ registerClasses()
+ }
+ }
+
+ override init() {
+ super.init()
+
+ updateSnapshot()
+ }
+
+ func setEditing(_ editing: Bool, animated: Bool) {
+ guard isEditing != editing else { return }
+
+ let oldSnapshot = snapshot
+ let oldDNSDomains = viewModel.customDNSDomains
+
+ isEditing = editing
+
+ if editing {
+ viewModelBeforeEditing = viewModel
+ } else {
+ viewModel.sanitizeCustomDNSEntries()
+ }
+
+ updateSnapshot()
+
+ // Reconfigure cells for items with corresponding DNS entries that were changed during sanitization.
+ let itemsToReload: [Item] = oldDNSDomains.filter { oldDNSEntry in
+ guard let newDNSEntry = viewModel.dnsEntry(entryIdentifier: oldDNSEntry.identifier) else { return false }
+
+ return newDNSEntry.address != oldDNSEntry.address
+ }.map { dnsEntry in
+ return .dnsServer(dnsEntry.identifier)
+ }
+
+ snapshot.reconfigureItems(itemsToReload)
+
+ if animated {
+ let diffResult = oldSnapshot.difference(snapshot)
+ if let tableView = tableView {
+ diffResult.apply(to: tableView, animateDifferences: animated)
+ }
+ } else {
+ tableView?.reloadData()
+ }
+
+ if !editing && viewModelBeforeEditing != viewModel {
+ delegate?.preferencesDataSource(self, didChangeViewModel: viewModel)
+ }
+ }
+
+ func update(from dnsSettings: DNSSettings) {
+ let newViewModel = PreferencesViewModel(from: dnsSettings)
+ let mergedViewModel = viewModel.merged(newViewModel)
+
+ if viewModel != mergedViewModel {
+ viewModel = mergedViewModel
+ updateSnapshot()
+ tableView?.reloadData()
+ }
+ }
+
+ // MARK: - UITableViewDataSource
+
+ func numberOfSections(in tableView: UITableView) -> Int {
+ return snapshot.numberOfSections()
+ }
+
+ func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
+ guard let sectionIdentifier = snapshot.section(at: section) else { return 0 }
+
+ return snapshot.numberOfItems(in: sectionIdentifier) ?? 0
+ }
+
+ func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
+ let item = snapshot.itemForIndexPath(indexPath)!
+
+ return dequeueCellForItem(item, in: tableView, at: indexPath)
+ }
+
+ func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
+ // Disable swipe to delete when not editing the table view
+ guard isEditing else { return false }
+
+ let item = snapshot.itemForIndexPath(indexPath)
+
+ switch item {
+ case .dnsServer, .addDNSServer:
+ return true
+ default:
+ return false
+ }
+ }
+
+ func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
+ let item = snapshot.itemForIndexPath(indexPath)
+
+ if case .addDNSServer = item, editingStyle == .insert {
+ addDNSServerEntry()
+ }
+
+ if case .dnsServer(let entryIdentifier) = item, editingStyle == .delete {
+ deleteDNSServerEntry(entryIdentifier: entryIdentifier)
+ }
+ }
+
+ func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool {
+ let item = snapshot.itemForIndexPath(indexPath)
+
+ switch item {
+ case .dnsServer:
+ return true
+ default:
+ return false
+ }
+ }
+
+ func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
+ let sourceItem = snapshot.itemForIndexPath(sourceIndexPath)!
+ let destinationItem = snapshot.itemForIndexPath(destinationIndexPath)!
+
+ guard case .dnsServer(let sourceIdentifier) = sourceItem,
+ case .dnsServer(let targetIdentifier) = destinationItem,
+ let sourceIndex = viewModel.indexOfDNSEntry(entryIdentifier: sourceIdentifier),
+ let destinationIndex = viewModel.indexOfDNSEntry(entryIdentifier: targetIdentifier) else { return }
+
+ let removedEntry = viewModel.customDNSDomains.remove(at: sourceIndex)
+ viewModel.customDNSDomains.insert(removedEntry, at: destinationIndex)
+
+ updateSnapshot()
+ }
+
+ // MARK: - UITableViewDelegate
+
+ func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool {
+ return false
+ }
+
+ func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
+ return tableView.dequeueReusableHeaderFooterView(withIdentifier: HeaderFooterReuseIdentifiers.spacer.rawValue)
+ }
+
+ func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? {
+ let sectionIdentifier = snapshot.section(at: section)!
+
+ switch sectionIdentifier {
+ case .mullvadDNS:
+ return nil
+
+ case .customDNS:
+ let reusableView = tableView.dequeueReusableHeaderFooterView(withIdentifier: HeaderFooterReuseIdentifiers.customDNSFooter.rawValue) as! SettingsStaticTextFooterView
+ configureFooterView(reusableView)
+ return reusableView
+ }
+ }
+
+ func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
+ return UIMetrics.sectionSpacing
+ }
+
+ func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat {
+ let sectionIdentifier = snapshot.section(at: section)!
+
+ switch sectionIdentifier {
+ case .mullvadDNS:
+ return 0
+
+ case .customDNS:
+ switch viewModel.customDNSPrecondition {
+ case .satisfied:
+ return 0
+ case .conflictsWithOtherSettings, .emptyDNSDomains:
+ return UITableView.automaticDimension
+ }
+ }
+ }
+
+ func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle {
+ let item = snapshot.itemForIndexPath(indexPath)
+
+ switch item {
+ case .dnsServer:
+ return .delete
+ case .addDNSServer:
+ return .insert
+ default:
+ return .none
+ }
+ }
+
+ func tableView(_ tableView: UITableView, targetIndexPathForMoveFromRowAt sourceIndexPath: IndexPath, toProposedIndexPath proposedDestinationIndexPath: IndexPath) -> IndexPath {
+ guard let sectionIdentifier = snapshot.section(at: sourceIndexPath.section),
+ case .customDNS = sectionIdentifier else { return sourceIndexPath }
+
+ let items = snapshot.items(in: sectionIdentifier)
+
+ let indexPathForFirstRow = items.first(where: Item.isDNSServerItem).flatMap { item in
+ return snapshot.indexPathForItem(item)
+ }
+
+ let indexPathForLastRow = items.last(where: Item.isDNSServerItem).flatMap { item in
+ return snapshot.indexPathForItem(item)
+ }
+
+ guard let indexPathForFirstRow = indexPathForFirstRow,
+ let indexPathForLastRow = indexPathForLastRow else { return sourceIndexPath }
+
+ if proposedDestinationIndexPath.section == sourceIndexPath.section {
+ return min(max(proposedDestinationIndexPath, indexPathForFirstRow), indexPathForLastRow)
+ } else {
+ if proposedDestinationIndexPath.section > sourceIndexPath.section {
+ return indexPathForLastRow
+ } else {
+ return indexPathForFirstRow
+ }
+ }
+ }
+
+ // MARK: - Private
+
+ private func registerClasses() {
+ CellReuseIdentifiers.allCases.forEach { enumCase in
+ tableView?.register(enumCase.reusableViewClass, forCellReuseIdentifier: enumCase.rawValue)
+ }
+
+ HeaderFooterReuseIdentifiers.allCases.forEach { enumCase in
+ tableView?.register(enumCase.reusableViewClass, forHeaderFooterViewReuseIdentifier: enumCase.rawValue)
+ }
+ }
+
+ private func updateSnapshot() {
+ var newSnapshot = DataSourceSnapshot<Section, Item>()
+ newSnapshot.appendSections([.mullvadDNS, .customDNS])
+ newSnapshot.appendItems([.blockAdvertising, .blockTracking], in: .mullvadDNS)
+ newSnapshot.appendItems([.useCustomDNS], in: .customDNS)
+
+ let dnsServerItems = viewModel.customDNSDomains.map { entry in
+ return Item.dnsServer(entry.identifier)
+ }
+ newSnapshot.appendItems(dnsServerItems, in: .customDNS)
+
+ if isEditing && viewModel.customDNSDomains.count < DNSSettings.maxAllowedCustomDNSDomains {
+ newSnapshot.appendItems([.addDNSServer], in: .customDNS)
+ }
+
+ snapshot = newSnapshot
+ }
+
+ private func dequeueCellForItem(_ item: Item, in tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell {
+ switch item {
+ case .blockAdvertising:
+ let cell = tableView.dequeueReusableCell(withIdentifier: CellReuseIdentifiers.settingSwitch.rawValue, for: indexPath) as! SettingsSwitchCell
+
+ cell.titleLabel.text = NSLocalizedString(
+ "BLOCK_ADS_CELL_LABEL",
+ tableName: "Preferences",
+ value: "Block ads",
+ comment: ""
+ )
+ cell.accessibilityHint = nil
+ cell.setOn(viewModel.blockAdvertising, animated: false)
+ cell.action = { [weak self] isOn in
+ self?.setBlockAdvertising(isOn)
+ }
+
+ return cell
+
+ case .blockTracking:
+ let cell = tableView.dequeueReusableCell(withIdentifier: CellReuseIdentifiers.settingSwitch.rawValue, for: indexPath) as! SettingsSwitchCell
+
+ cell.titleLabel.text = NSLocalizedString(
+ "BLOCK_TRACKERS_CELL_LABEL",
+ tableName: "Preferences",
+ value: "Block trackers",
+ comment: ""
+ )
+ cell.accessibilityHint = nil
+ cell.setOn(viewModel.blockTracking, animated: false)
+ cell.action = { [weak self] isOn in
+ self?.setBlockTracking(isOn)
+ }
+
+ return cell
+
+ case .useCustomDNS:
+ let cell = tableView.dequeueReusableCell(withIdentifier: CellReuseIdentifiers.settingSwitch.rawValue, for: indexPath) as! SettingsSwitchCell
+
+ cell.titleLabel.text = NSLocalizedString(
+ "CUSTOM_DNS_CELL_LABEL",
+ tableName: "Preferences",
+ value: "Use custom DNS",
+ comment: ""
+ )
+ cell.setEnabled(viewModel.customDNSPrecondition == .satisfied)
+ cell.setOn(viewModel.effectiveEnableCustomDNS, animated: false)
+ cell.action = { [weak self] isOn in
+ self?.setEnableCustomDNS(isOn)
+ }
+
+ switch viewModel.customDNSPrecondition {
+ case .satisfied:
+ cell.accessibilityHint = nil
+
+ case .emptyDNSDomains:
+ cell.accessibilityHint = NSLocalizedString(
+ "CUSTOM_DNS_NO_DNS_ENTRIES_ACCESSIBILITY_HINT",
+ tableName: "Preferences",
+ value: "Please add at least one DNS domain to activate this setting.",
+ comment: ""
+ )
+
+ case .conflictsWithOtherSettings:
+ cell.accessibilityHint = NSLocalizedString(
+ "CUSTOM_DNS_DISABLE_ADTRACKER_BLOCKING_ACCESSIBILITY_HINT",
+ tableName: "Preferences",
+ value: "Disable Block Ads and Block trackers to activate this setting.",
+ comment: ""
+ )
+ }
+
+
+ return cell
+
+ case .addDNSServer:
+ let cell = tableView.dequeueReusableCell(withIdentifier: CellReuseIdentifiers.addDNSServer.rawValue, for: indexPath) as! SettingsAddDNSEntryCell
+ cell.titleLabel.text = NSLocalizedString(
+ "ADD_CUSTOM_DNS_SERVER_CELL_LABEL",
+ tableName: "Preferences",
+ value: "Add a server",
+ comment: ""
+ )
+
+ cell.actionHandler = { [weak self] cell in
+ self?.addDNSServerEntry()
+ }
+
+ return cell
+
+ case .dnsServer(let entryIdentifier):
+ let dnsServerEntry = viewModel.dnsEntry(entryIdentifier: entryIdentifier)!
+
+ let cell = tableView.dequeueReusableCell(withIdentifier: CellReuseIdentifiers.dnsServer.rawValue, for: indexPath) as! SettingsDNSTextCell
+ cell.textField.text = dnsServerEntry.address
+ cell.isValidInput = viewModel.validateDNSDomainUserInput(dnsServerEntry.address)
+
+ cell.onTextChange = { [weak self] cell in
+ guard let self = self, let indexPath = self.tableView?.indexPath(for: cell) else { return }
+
+ if case .dnsServer(let entryIdentifier) = self.snapshot.itemForIndexPath(indexPath) {
+ self.handleDNSEntryChange(entryIdentifier: entryIdentifier, cell: cell)
+ }
+ }
+
+ cell.onReturnKey = { cell in
+ cell.endEditing(false)
+ }
+
+ return cell
+ }
+ }
+
+ private func setBlockAdvertising(_ isEnabled: Bool) {
+ let oldViewModel = viewModel
+
+ viewModel.setBlockAdvertising(isEnabled)
+
+ if oldViewModel.customDNSPrecondition != viewModel.customDNSPrecondition {
+ reloadCustomDNSFooter()
+ }
+
+ if !isEditing {
+ delegate?.preferencesDataSource(self, didChangeViewModel: viewModel)
+ }
+ }
+
+ private func setBlockTracking(_ isEnabled: Bool) {
+ let oldViewModel = viewModel
+
+ viewModel.setBlockTracking(isEnabled)
+
+ if oldViewModel.customDNSPrecondition != viewModel.customDNSPrecondition {
+ reloadCustomDNSFooter()
+ }
+
+ if !isEditing {
+ delegate?.preferencesDataSource(self, didChangeViewModel: viewModel)
+ }
+ }
+
+ private func setEnableCustomDNS(_ isEnabled: Bool) {
+ viewModel.setEnableCustomDNS(isEnabled)
+
+ reloadCustomDNSFooter()
+
+ if !isEditing {
+ delegate?.preferencesDataSource(self, didChangeViewModel: viewModel)
+ }
+ }
+
+ private func handleDNSEntryChange(entryIdentifier: UUID, cell: SettingsDNSTextCell) {
+ let string = cell.textField.text ?? ""
+ let oldViewModel = viewModel
+
+ viewModel.updateDNSEntry(entryIdentifier: entryIdentifier, newAddress: string)
+ cell.isValidInput = viewModel.validateDNSDomainUserInput(string)
+
+ if oldViewModel.customDNSPrecondition != viewModel.customDNSPrecondition {
+ reloadCustomDNSFooter()
+ }
+ }
+
+ private func addDNSServerEntry() {
+ let oldViewModel = viewModel
+
+ let newDNSEntry = DNSServerEntry(address: "")
+ viewModel.customDNSDomains.append(newDNSEntry)
+
+ let oldSnapshot = snapshot
+ updateSnapshot()
+
+ let diffResult = oldSnapshot.difference(snapshot)
+ if let tableView = tableView {
+ diffResult.apply(to: tableView, animateDifferences: true) { completed in
+ if oldViewModel.customDNSPrecondition != self.viewModel.customDNSPrecondition {
+ self.reloadCustomDNSFooter()
+ }
+
+ if completed {
+ // Focus on the new entry text field.
+ let lastDNSEntry = self.snapshot.items(in: .customDNS).last { item in
+ if case .dnsServer(let entryIdentifier) = item {
+ return entryIdentifier == newDNSEntry.identifier
+ } else {
+ return false
+ }
+ }
+
+ if let lastDNSEntry = lastDNSEntry, let indexPath = self.snapshot.indexPathForItem(lastDNSEntry) {
+ let cell = self.tableView?.cellForRow(at: indexPath) as? SettingsDNSTextCell
+
+ self.tableView?.scrollToRow(at: indexPath, at: .bottom, animated: true)
+ cell?.textField.becomeFirstResponder()
+ }
+ }
+ }
+ }
+ }
+
+ private func deleteDNSServerEntry(entryIdentifier: UUID) {
+ let oldViewModel = viewModel
+ let oldSnapshot = snapshot
+
+ let entryIndex = viewModel.customDNSDomains.firstIndex { entry in
+ return entry.identifier == entryIdentifier
+ }
+
+ guard let entryIndex = entryIndex else { return }
+
+ viewModel.customDNSDomains.remove(at: entryIndex)
+ updateSnapshot()
+
+ let diffResult = oldSnapshot.difference(snapshot)
+
+ if let tableView = tableView {
+ diffResult.apply(to: tableView, animateDifferences: true) { completed in
+ if oldViewModel.customDNSPrecondition != self.viewModel.customDNSPrecondition {
+ self.reloadCustomDNSFooter()
+ }
+ }
+ }
+ }
+
+ private func reloadCustomDNSFooter() {
+ let sectionIndex = snapshot.indexOfSection(.customDNS)!
+ let indexPath = snapshot.indexPathForItem(.useCustomDNS)!
+
+ // Reload footer view
+ tableView?.performBatchUpdates {
+ if let reusableView = tableView?.footerView(forSection: sectionIndex) as? SettingsStaticTextFooterView {
+ configureFooterView(reusableView)
+ }
+ }
+
+ // Reload "Use custom DNS" row
+ if let cell = tableView?.cellForRow(at: indexPath) as? SettingsSwitchCell {
+ cell.setEnabled(viewModel.customDNSPrecondition == .satisfied)
+ cell.setOn(viewModel.effectiveEnableCustomDNS, animated: true)
+ }
+ }
+
+ private func configureFooterView(_ reusableView: SettingsStaticTextFooterView) {
+ let footerText: String?
+
+ switch viewModel.customDNSPrecondition {
+ case .satisfied:
+ footerText = nil
+
+ case .emptyDNSDomains:
+ footerText = NSLocalizedString(
+ "CUSTOM_DNS_NO_DNS_ENTRIES_FOOTNOTE",
+ tableName: "Preferences",
+ value: "Please add at least one DNS domain to activate this setting.",
+ comment: ""
+ )
+
+ case .conflictsWithOtherSettings:
+ footerText = NSLocalizedString(
+ "CUSTOM_DNS_DISABLE_ADTRACKER_BLOCKING_FOOTNOTE",
+ tableName: "Preferences",
+ value: "Disable Block Ads and Block trackers to activate this setting.",
+ comment: ""
+ )
+ }
+
+ reusableView.titleLabel.text = footerText
+ }
+
+}
diff --git a/ios/MullvadVPN/PreferencesDataSourceDelegate.swift b/ios/MullvadVPN/PreferencesDataSourceDelegate.swift
new file mode 100644
index 0000000000..1de9aef42b
--- /dev/null
+++ b/ios/MullvadVPN/PreferencesDataSourceDelegate.swift
@@ -0,0 +1,13 @@
+//
+// PreferencesDataSourceDelegate.swift
+// MullvadVPN
+//
+// Created by pronebird on 11/10/2021.
+// Copyright © 2021 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+
+protocol PreferencesDataSourceDelegate: AnyObject {
+ func preferencesDataSource(_ dataSource: PreferencesDataSource, didChangeViewModel viewModel: PreferencesViewModel)
+}
diff --git a/ios/MullvadVPN/PreferencesViewController.swift b/ios/MullvadVPN/PreferencesViewController.swift
index 6f32ca970d..f1ff265002 100644
--- a/ios/MullvadVPN/PreferencesViewController.swift
+++ b/ios/MullvadVPN/PreferencesViewController.swift
@@ -9,16 +9,11 @@
import UIKit
import Logging
-class PreferencesViewController: UITableViewController, TunnelObserver {
+class PreferencesViewController: UITableViewController, PreferencesDataSourceDelegate, TunnelObserver {
private let logger = Logger(label: "PreferencesViewController")
- private var dnsSettings: DNSSettings?
- private enum CellIdentifier: String {
- case switchCell
- }
-
- private let staticDataSource = PreferencesTableViewDataSource()
+ private let dataSource = PreferencesDataSource()
init() {
super.init(style: .grouped)
@@ -35,75 +30,39 @@ class PreferencesViewController: UITableViewController, TunnelObserver {
tableView.separatorColor = .secondaryColor
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 60
- tableView.sectionHeaderHeight = UIMetrics.sectionSpacing
- tableView.sectionFooterHeight = 0
- tableView.dataSource = staticDataSource
- tableView.delegate = staticDataSource
-
- tableView.register(SettingsSwitchCell.self, forCellReuseIdentifier: CellIdentifier.switchCell.rawValue)
- tableView.register(EmptyTableViewHeaderFooterView.self, forHeaderFooterViewReuseIdentifier: EmptyTableViewHeaderFooterView.reuseIdentifier)
+ dataSource.tableView = tableView
+ dataSource.delegate = self
navigationItem.title = NSLocalizedString("NAVIGATION_TITLE", tableName: "Preferences", comment: "Navigation title")
- navigationItem.largeTitleDisplayMode = .always
+ navigationItem.rightBarButtonItem = editButtonItem
TunnelManager.shared.addObserver(self)
- self.dnsSettings = TunnelManager.shared.tunnelInfo?.tunnelSettings.interface.dnsSettings
-
- setupDataSource()
- }
-
- // MARK: - TunnelObserver
-
- func tunnelManager(_ manager: TunnelManager, didUpdateTunnelState tunnelState: TunnelState) {
- // no-op
- }
-
- func tunnelManager(_ manager: TunnelManager, didFailWithError error: TunnelManager.Error) {
- // no-op
- }
- func tunnelManager(_ manager: TunnelManager, didUpdateTunnelSettings tunnelInfo: TunnelInfo?) {
- if tunnelInfo?.tunnelSettings.interface.dnsSettings != self.dnsSettings {
- self.dnsSettings = tunnelInfo?.tunnelSettings.interface.dnsSettings
- self.tableView.reloadData()
+ if let dnsSettings = TunnelManager.shared.tunnelInfo?.tunnelSettings.interface.dnsSettings {
+ dataSource.update(from: dnsSettings)
}
}
- // MARK: - Private
+ override func setEditing(_ editing: Bool, animated: Bool) {
+ dataSource.setEditing(editing, animated: animated)
- private func setupDataSource() {
- let blockAdvertisingRow = StaticTableViewRow(reuseIdentifier: CellIdentifier.switchCell.rawValue) { (indexPath, cell) in
- let cell = cell as! SettingsSwitchCell
+ navigationItem.setHidesBackButton(editing, animated: animated)
- cell.titleLabel.text = NSLocalizedString("BLOCK_ADS_CELL_LABEL", tableName: "Preferences", comment: "")
- cell.setOn(self.dnsSettings?.blockAdvertising ?? false, animated: false)
- cell.action = { [weak self] (isOn) in
- self?.dnsSettings?.blockAdvertising = isOn
- self?.saveDNSSettings()
- }
- }
- blockAdvertisingRow.isSelectable = false
-
- let blockTrackingRow = StaticTableViewRow(reuseIdentifier: CellIdentifier.switchCell.rawValue) { (indexPath, cell) in
- let cell = cell as! SettingsSwitchCell
-
- cell.titleLabel.text = NSLocalizedString("BLOCK_TRACKERS_CELL_LABEL", tableName: "Preferences", comment: "")
- cell.setOn(self.dnsSettings?.blockTracking ?? false, animated: false)
- cell.action = { [weak self] (isOn) in
- self?.dnsSettings?.blockTracking = isOn
- self?.saveDNSSettings()
- }
+ if #available(iOS 13.0, *) {
+ // Disable swipe to dismiss when editing
+ isModalInPresentation = editing
+ } else {
+ // no-op
}
- blockTrackingRow.isSelectable = false
- let section = StaticTableViewSection()
- section.addRows([blockAdvertisingRow, blockTrackingRow])
- staticDataSource.addSections([section])
+ super.setEditing(editing, animated: animated)
}
- private func saveDNSSettings() {
- guard let dnsSettings = dnsSettings else { return }
+ // MARK: - PreferencesDataSourceDelegate
+
+ func preferencesDataSource(_ dataSource: PreferencesDataSource, didChangeViewModel dataModel: PreferencesViewModel) {
+ let dnsSettings = dataModel.asDNSSettings()
TunnelManager.shared.setDNSSettings(dnsSettings)
.onFailure { [weak self] error in
@@ -112,14 +71,20 @@ class PreferencesViewController: UITableViewController, TunnelObserver {
.observe { _ in }
}
-}
+ // MARK: - TunnelObserver
-class PreferencesTableViewDataSource: StaticTableViewDataSource {
+ func tunnelManager(_ manager: TunnelManager, didUpdateTunnelState tunnelState: TunnelState) {
+ // no-op
+ }
+
+ func tunnelManager(_ manager: TunnelManager, didFailWithError error: TunnelManager.Error) {
+ // no-op
+ }
- // MARK: - UITableViewDelegate
+ func tunnelManager(_ manager: TunnelManager, didUpdateTunnelSettings tunnelInfo: TunnelInfo?) {
+ guard let dnsSettings = tunnelInfo?.tunnelSettings.interface.dnsSettings else { return }
- func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
- return tableView.dequeueReusableHeaderFooterView(withIdentifier: EmptyTableViewHeaderFooterView.reuseIdentifier)
+ dataSource.update(from: dnsSettings)
}
}
diff --git a/ios/MullvadVPN/PreferencesViewModel.swift b/ios/MullvadVPN/PreferencesViewModel.swift
new file mode 100644
index 0000000000..b7733b1d2e
--- /dev/null
+++ b/ios/MullvadVPN/PreferencesViewModel.swift
@@ -0,0 +1,164 @@
+//
+// PreferencesViewModel.swift
+// MullvadVPN
+//
+// Created by pronebird on 11/10/2021.
+// Copyright © 2021 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+
+enum CustomDNSPrecondition {
+ /// Custom DNS can be enabled
+ case satisfied
+
+ /// Custom DNS cannot be enabled as it would conflict with other settings.
+ case conflictsWithOtherSettings
+
+ /// No valid DNS server entries.
+ case emptyDNSDomains
+}
+
+struct DNSServerEntry: Equatable, Hashable {
+ var identifier = UUID()
+ var address: String
+}
+
+struct PreferencesViewModel: Equatable {
+ private(set) var blockAdvertising: Bool
+ private(set) var blockTracking: Bool
+ private(set) var enableCustomDNS: Bool
+ var customDNSDomains: [DNSServerEntry]
+
+ mutating func setBlockAdvertising(_ newValue: Bool) {
+ blockAdvertising = newValue
+ enableCustomDNS = false
+ }
+
+ mutating func setBlockTracking(_ newValue: Bool) {
+ blockTracking = newValue
+ enableCustomDNS = false
+ }
+
+ mutating func setEnableCustomDNS(_ newValue: Bool) {
+ blockTracking = false
+ blockAdvertising = false
+ enableCustomDNS = newValue
+ }
+
+ /// Precondition for enabling Custom DNS.
+ var customDNSPrecondition: CustomDNSPrecondition {
+ if blockAdvertising || blockTracking {
+ return .conflictsWithOtherSettings
+ } else {
+ let hasValidDNSDomains = customDNSDomains.contains { entry in
+ return AnyIPAddress(entry.address) != nil
+ }
+
+ if hasValidDNSDomains {
+ return .satisfied
+ } else {
+ return .emptyDNSDomains
+ }
+ }
+ }
+
+ /// Effective state of the custom DNS setting.
+ var effectiveEnableCustomDNS: Bool {
+ return customDNSPrecondition == .satisfied && enableCustomDNS
+ }
+
+ init(from dnsSettings: DNSSettings = DNSSettings()) {
+ blockAdvertising = dnsSettings.blockAdvertising
+ blockTracking = dnsSettings.blockTracking
+ enableCustomDNS = dnsSettings.enableCustomDNS
+ customDNSDomains = dnsSettings.customDNSDomains.map { ipAddress in
+ return DNSServerEntry(identifier: UUID(), address: "\(ipAddress)")
+ }
+ }
+
+ /// Produce merged view model keeping entry `identifier` for matching DNS entries.
+ func merged(_ other: PreferencesViewModel) -> PreferencesViewModel {
+ var mergedViewModel = PreferencesViewModel()
+
+ mergedViewModel.blockAdvertising = other.blockAdvertising
+ mergedViewModel.blockTracking = other.blockTracking
+ mergedViewModel.enableCustomDNS = other.enableCustomDNS
+
+ var oldDNSDomains = customDNSDomains
+ for otherEntry in other.customDNSDomains {
+ let sameEntryIndex = oldDNSDomains.firstIndex { entry in
+ return entry.address == otherEntry.address
+ }
+
+ if let sameEntryIndex = sameEntryIndex {
+ let sourceEntry = oldDNSDomains[sameEntryIndex]
+
+ mergedViewModel.customDNSDomains.append(sourceEntry)
+ oldDNSDomains.remove(at: sameEntryIndex)
+ } else {
+ mergedViewModel.customDNSDomains.append(otherEntry)
+ }
+ }
+
+ return mergedViewModel
+ }
+
+ /// Sanitize custom DNS entries.
+ mutating func sanitizeCustomDNSEntries() {
+ // Santize DNS domains, drop invalid entries.
+ customDNSDomains = customDNSDomains.compactMap { entry in
+ if let canonicalAddress = AnyIPAddress(entry.address) {
+ var newEntry = entry
+ newEntry.address = "\(canonicalAddress)"
+ return newEntry
+ } else {
+ return nil
+ }
+ }
+
+ // Toggle off custom DNS when no domains specified.
+ if customDNSDomains.isEmpty {
+ enableCustomDNS = false
+ }
+ }
+
+ func dnsEntry(entryIdentifier: UUID) -> DNSServerEntry? {
+ return customDNSDomains.first { entry in
+ return entry.identifier == entryIdentifier
+ }
+ }
+
+ /// Returns an index of entry in `customDNSDomains`, otherwise `nil`.
+ func indexOfDNSEntry(entryIdentifier: UUID) -> Int? {
+ return customDNSDomains.firstIndex { entry in
+ return entry.identifier == entryIdentifier
+ }
+ }
+
+ /// Update the address for the DNS entry with the given UUID.
+ mutating func updateDNSEntry(entryIdentifier: UUID, newAddress: String) {
+ guard let index = indexOfDNSEntry(entryIdentifier: entryIdentifier) else { return }
+
+ var entry = customDNSDomains[index]
+ entry.address = newAddress
+ customDNSDomains[index] = entry
+ }
+
+ /// Converts view model into `DNSSettings`.
+ func asDNSSettings() -> DNSSettings {
+ var dnsSettings = DNSSettings()
+ dnsSettings.blockAdvertising = blockAdvertising
+ dnsSettings.blockTracking = blockTracking
+ dnsSettings.enableCustomDNS = enableCustomDNS
+ dnsSettings.customDNSDomains = customDNSDomains.compactMap { entry in
+ return AnyIPAddress(entry.address)
+ }
+ return dnsSettings
+ }
+
+ /// Returns true if the given string is empty or a valid IP address.
+ func validateDNSDomainUserInput(_ string: String) -> Bool {
+ return string.isEmpty || AnyIPAddress(string) != nil
+ }
+}
diff --git a/ios/MullvadVPN/SelectLocationCell.swift b/ios/MullvadVPN/SelectLocationCell.swift
index d297b719dd..9cae5a6cfa 100644
--- a/ios/MullvadVPN/SelectLocationCell.swift
+++ b/ios/MullvadVPN/SelectLocationCell.swift
@@ -11,7 +11,7 @@ import UIKit
private let kCollapseButtonWidth: CGFloat = 24
private let kRelayIndicatorSize: CGFloat = 16
-class SelectLocationCell: BasicTableViewCell {
+class SelectLocationCell: UITableViewCell {
typealias CollapseHandler = (SelectLocationCell) -> Void
let locationLabel = UILabel()
@@ -53,11 +53,10 @@ class SelectLocationCell: BasicTableViewCell {
var didCollapseHandler: CollapseHandler?
- private let preferredMargins = UIEdgeInsets(top: 16, left: 28, bottom: 16, right: 12)
-
override var indentationLevel: Int {
didSet {
updateBackgroundColor()
+ setLayoutMargins()
}
}
@@ -71,17 +70,13 @@ class SelectLocationCell: BasicTableViewCell {
fatalError("init(coder:) has not been implemented")
}
- override func layoutSubviews() {
- super.layoutSubviews()
+ private func setLayoutMargins() {
+ let indentation = CGFloat(indentationLevel) * indentationWidth
- let indentPoints = CGFloat(indentationLevel) * indentationWidth
+ var contentMargins = UIMetrics.selectLocationCellLayoutMargins
+ contentMargins.left += indentation
- contentView.frame = CGRect(
- x: indentPoints,
- y: contentView.frame.origin.y,
- width: contentView.frame.size.width - indentPoints,
- height: contentView.frame.size.height
- )
+ contentView.layoutMargins = contentMargins
}
override func setHighlighted(_ highlighted: Bool, animated: Bool) {
@@ -98,10 +93,16 @@ class SelectLocationCell: BasicTableViewCell {
}
private func setupCell() {
- indentationWidth = 16
+ indentationWidth = UIMetrics.cellIndentationWidth
backgroundColor = .clear
- contentView.layoutMargins = preferredMargins
+ contentView.backgroundColor = .clear
+
+ backgroundView = UIView()
+ backgroundView?.backgroundColor = UIColor.Cell.backgroundColor
+
+ selectedBackgroundView = UIView()
+ selectedBackgroundView?.backgroundColor = UIColor.Cell.selectedBackgroundColor
locationLabel.font = UIFont.systemFont(ofSize: 17)
locationLabel.textColor = .white
@@ -122,6 +123,7 @@ class SelectLocationCell: BasicTableViewCell {
updateAccessibilityCustomActions()
updateDisabled()
updateBackgroundColor()
+ setLayoutMargins()
NSLayoutConstraint.activate([
tickImageView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor),
diff --git a/ios/MullvadVPN/SettingsAddDNSEntryCell.swift b/ios/MullvadVPN/SettingsAddDNSEntryCell.swift
new file mode 100644
index 0000000000..a2db42e6ba
--- /dev/null
+++ b/ios/MullvadVPN/SettingsAddDNSEntryCell.swift
@@ -0,0 +1,32 @@
+//
+// SettingsAddDNSEntryCell.swift
+// MullvadVPN
+//
+// Created by pronebird on 27/10/2021.
+// Copyright © 2021 Mullvad VPN AB. All rights reserved.
+//
+
+import UIKit
+
+class SettingsAddDNSEntryCell: SettingsCell {
+ var actionHandler: ((SettingsAddDNSEntryCell) -> Void)?
+
+ override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
+ super.init(style: style, reuseIdentifier: reuseIdentifier)
+
+ backgroundView?.backgroundColor = UIColor.SubSubCell.backgroundColor
+
+ let gestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
+ contentView.addGestureRecognizer(gestureRecognizer)
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ @objc func handleTap(_ sender: UIGestureRecognizer) {
+ if case .ended = sender.state {
+ actionHandler?(self)
+ }
+ }
+}
diff --git a/ios/MullvadVPN/SettingsCell.swift b/ios/MullvadVPN/SettingsCell.swift
index b8d46fe148..8e25b9c387 100644
--- a/ios/MullvadVPN/SettingsCell.swift
+++ b/ios/MullvadVPN/SettingsCell.swift
@@ -8,23 +8,23 @@
import UIKit
-class SettingsCell: BasicTableViewCell {
+class SettingsCell: UITableViewCell {
let titleLabel = UILabel()
let detailTitleLabel = UILabel()
- private let preferredMargins = UIEdgeInsets(top: 16, left: 24, bottom: 16, right: 12)
- private var appDidBecomeActiveObserver: NSObjectProtocol?
-
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
- tintColor = .white
+ backgroundView = UIView()
backgroundView?.backgroundColor = UIColor.Cell.backgroundColor
+
+ selectedBackgroundView = UIView()
selectedBackgroundView?.backgroundColor = UIColor.Cell.selectedAltBackgroundColor
- contentView.layoutMargins = preferredMargins
separatorInset = .zero
+ backgroundColor = .clear
+ contentView.backgroundColor = .clear
titleLabel.translatesAutoresizingMaskIntoConstraints = false
titleLabel.font = UIFont.systemFont(ofSize: 17)
@@ -43,6 +43,8 @@ class SettingsCell: BasicTableViewCell {
contentView.addSubview(titleLabel)
contentView.addSubview(detailTitleLabel)
+ setLayoutMargins()
+
NSLayoutConstraint.activate([
titleLabel.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor),
titleLabel.topAnchor.constraint(equalTo: contentView.layoutMarginsGuide.topAnchor),
@@ -54,46 +56,95 @@ class SettingsCell: BasicTableViewCell {
detailTitleLabel.topAnchor.constraint(equalTo: contentView.layoutMarginsGuide.topAnchor),
detailTitleLabel.bottomAnchor.constraint(equalTo: contentView.layoutMarginsGuide.bottomAnchor),
])
-
- enableDisclosureViewTintColorFix()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
+ override func prepareForReuse() {
+ super.prepareForReuse()
+
+ setLayoutMargins()
+ }
+
override func didAddSubview(_ subview: UIView) {
super.didAddSubview(subview)
if let button = subview as? UIButton {
- updateDisclosureButtonBackgroundImageRenderingMode(button)
+ updateDisclosureIndicatorTintColor(button)
}
}
- /// `UITableViewCell` resets the disclosure view image when the app goes in background
- /// This fix ensures that the image is tinted when the app becomes active again.
- private func enableDisclosureViewTintColorFix() {
- appDidBecomeActiveObserver = NotificationCenter.default.addObserver(
- forName: UIApplication.didBecomeActiveNotification,
- object: nil,
- queue: nil) { [weak self] (note) in
- self?.updateDisclosureViewTintColor()
+ override func layoutSubviews() {
+ super.layoutSubviews()
+
+ if #available(iOS 13, *) {
+ // no-op
+ } else {
+ layoutSubviewsiOS12()
}
+ }
+
+ private func setLayoutMargins() {
+ // Set layout margins for standard acceessories added into the cell (reorder control, etc..)
+ layoutMargins = UIMetrics.settingsCellLayoutMargins
- updateDisclosureViewTintColor()
+ // Set layout margins for cell content
+ contentView.layoutMargins = UIMetrics.settingsCellLayoutMargins
}
- /// For some reason the `tintColor` is not applied to standard accessory views.
- /// Fix this by looking for the accessory button and changing the image rendering mode
- private func updateDisclosureViewTintColor() {
- for case let button as UIButton in subviews {
- updateDisclosureButtonBackgroundImageRenderingMode(button)
+ /// Standard disclosure views do not provide customization of a tint color.
+ /// This method adjusts a disclosure tint color by replacing the button image rendering mode on iOS 12 and by
+ /// switching graphics on iOS 13 or newer.
+ private func updateDisclosureIndicatorTintColor(_ button: UIButton) {
+ guard accessoryType == .disclosureIndicator else { return }
+
+ if #available(iOS 13, *) {
+ let configuration = UIImage.SymbolConfiguration(pointSize: 11, weight: .bold)
+ let chevron = UIImage(systemName: "chevron.right", withConfiguration: configuration)?
+ .withTintColor(.white, renderingMode: .alwaysOriginal)
+
+ button.setImage(chevron, for: .normal)
+ } else {
+ if let image = button.backgroundImage(for: .normal)?.withRenderingMode(.alwaysTemplate) {
+ button.setBackgroundImage(image, for: .normal)
+ button.tintColor = .white
+ }
}
}
- private func updateDisclosureButtonBackgroundImageRenderingMode(_ button: UIButton) {
- if let image = button.backgroundImage(for: .normal)?.withRenderingMode(.alwaysTemplate) {
- button.setBackgroundImage(image, for: .normal)
+ /// On iOS 12, standard edit and reorder controls do not respect layout margins.
+ /// This method does layout adjustments to fix that.
+ private func layoutSubviewsiOS12() {
+ guard isEditing || showsReorderControl else { return }
+
+ var leftOffset: CGFloat = 0
+ var rightOffset: CGFloat = 0
+
+ for subview in subviews {
+ // Detect the edit control and move it, so that the nested image view is aligned along the left edge of the
+ // layout margins.
+ if subview.description.starts(with: "<UITableViewCellEditControl"), let imageView = subview.subviews.first {
+ let imageOffset = imageView.frame.minX
+ var pos = subview.frame.origin
+ pos.x = layoutMargins.left - imageOffset
+ subview.frame.origin = pos
+ leftOffset = pos.x
+ }
+
+ // Detect the reorder control and move it, so that its right edge is aligned along the right edge of the
+ // layout margins.
+ if subview.description.starts(with: "<UITableViewCellReorderControl") {
+ var pos = subview.frame.origin
+ pos.x -= layoutMargins.right
+ subview.frame.origin = pos
+ rightOffset = layoutMargins.right
+ }
}
+
+ // Adjust the content view to account for the adjustments to the edit and reorder controls.
+ let contentInset = UIEdgeInsets(top: 0, left: leftOffset, bottom: 0, right: rightOffset)
+ contentView.frame = contentView.frame.inset(by: contentInset)
}
}
diff --git a/ios/MullvadVPN/SettingsDNSTextCell.swift b/ios/MullvadVPN/SettingsDNSTextCell.swift
new file mode 100644
index 0000000000..e98300781e
--- /dev/null
+++ b/ios/MullvadVPN/SettingsDNSTextCell.swift
@@ -0,0 +1,139 @@
+//
+// SettingsDNSTextCell.swift
+// MullvadVPN
+//
+// Created by pronebird on 05/10/2021.
+// Copyright © 2021 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+import UIKit
+
+class SettingsDNSTextCell: SettingsCell, UITextFieldDelegate {
+
+ var isValidInput: Bool = true {
+ didSet {
+ updateCellAppearance(animated: false)
+ }
+ }
+
+ let textField = CustomTextField()
+
+ var onTextChange: ((SettingsDNSTextCell) -> Void)?
+ var onReturnKey: ((SettingsDNSTextCell) -> Void)?
+
+ override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
+ super.init(style: style, reuseIdentifier: reuseIdentifier)
+
+ textField.translatesAutoresizingMaskIntoConstraints = false
+ textField.font = UIFont.systemFont(ofSize: 17)
+ textField.backgroundColor = .clear
+ textField.textColor = UIColor.TextField.textColor
+ textField.textMargins = UIMetrics.settingsCellLayoutMargins
+ textField.placeholder = NSLocalizedString("DNS_TEXT_CELL_PLACEHOLDER", tableName: "Settings", value: "Enter IP", comment: "")
+ textField.cornerRadius = 0
+ textField.keyboardType = .numbersAndPunctuation
+ textField.returnKeyType = .done
+ textField.autocorrectionType = .no
+ textField.smartInsertDeleteType = .no
+ textField.smartDashesType = .no
+ textField.smartQuotesType = .no
+ textField.spellCheckingType = .no
+ textField.autocapitalizationType = .none
+ textField.delegate = self
+
+ NotificationCenter.default.addObserver(
+ self,
+ selector: #selector(textDidChange),
+ name: UITextField.textDidChangeNotification,
+ object: textField
+ )
+
+ backgroundView?.backgroundColor = UIColor.TextField.backgroundColor
+ contentView.addSubview(textField)
+
+ if #available(iOS 13.0, *) {
+ overrideUserInterfaceStyle = .light
+ }
+
+ NSLayoutConstraint.activate([
+ textField.topAnchor.constraint(equalTo: contentView.topAnchor),
+ textField.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
+ textField.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
+ textField.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
+ ])
+
+ updateCellAppearance(animated: false)
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func prepareForReuse() {
+ super.prepareForReuse()
+
+ onTextChange = nil
+ onReturnKey = nil
+
+ textField.text = ""
+ isValidInput = true
+ }
+
+ override func setEditing(_ editing: Bool, animated: Bool) {
+ super.setEditing(editing, animated: animated)
+
+ updateCellAppearance(animated: animated)
+ }
+
+ @objc func textDidChange() {
+ onTextChange?(self)
+ }
+
+ private func updateCellAppearance(animated: Bool) {
+ if animated {
+ UIView.animate(withDuration: 0.25) {
+ self.updateCellAppearance()
+ }
+ } else {
+ updateCellAppearance()
+ }
+ }
+
+ private func updateCellAppearance() {
+ textField.isEnabled = isEditing
+
+ if isEditing {
+ if isValidInput {
+ textField.textColor = UIColor.TextField.textColor
+ } else {
+ textField.textColor = UIColor.TextField.invalidInputTextColor
+ }
+
+ backgroundView?.backgroundColor = UIColor.TextField.backgroundColor
+ } else {
+ textField.textColor = .white
+
+ backgroundView?.backgroundColor = UIColor.SubCell.backgroundColor
+ }
+ }
+
+ // MARK: - UITextFieldDelegate
+
+ func textFieldShouldReturn(_ textField: UITextField) -> Bool {
+ onReturnKey?(self)
+ return true
+ }
+
+ func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
+ let ipv4AddressCharset = CharacterSet.ipv4AddressCharset
+ let ipv6AddressCharset = CharacterSet.ipv6AddressCharset
+
+ return [ipv4AddressCharset, ipv6AddressCharset].contains { charset in
+ return string.unicodeScalars.allSatisfy { scalar in
+ return charset.contains(scalar)
+ }
+ }
+ }
+
+}
diff --git a/ios/MullvadVPN/SettingsStaticTextFooterView.swift b/ios/MullvadVPN/SettingsStaticTextFooterView.swift
new file mode 100644
index 0000000000..95fd211602
--- /dev/null
+++ b/ios/MullvadVPN/SettingsStaticTextFooterView.swift
@@ -0,0 +1,41 @@
+//
+// SettingsStaticTextFooterView.swift
+// MullvadVPN
+//
+// Created by pronebird on 05/10/2021.
+// Copyright © 2021 Mullvad VPN AB. All rights reserved.
+//
+
+import UIKit
+
+class SettingsStaticTextFooterView: UITableViewHeaderFooterView {
+ let titleLabel: UILabel = {
+ let titleLabel = UILabel()
+ titleLabel.translatesAutoresizingMaskIntoConstraints = false
+ titleLabel.font = UIFont.systemFont(ofSize: 14)
+ titleLabel.textColor = .white
+ titleLabel.numberOfLines = 0
+ return titleLabel
+ }()
+
+ override init(reuseIdentifier: String?) {
+ super.init(reuseIdentifier: reuseIdentifier)
+
+ contentView.layoutMargins = UIMetrics.settingsCellLayoutMargins
+ contentView.addSubview(titleLabel)
+
+ let bottomConstraint = titleLabel.bottomAnchor.constraint(equalTo: contentView.layoutMarginsGuide.bottomAnchor)
+ bottomConstraint.priority = .defaultLow
+
+ contentView.addConstraints([
+ titleLabel.topAnchor.constraint(equalTo: contentView.layoutMarginsGuide.topAnchor),
+ titleLabel.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor),
+ titleLabel.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor),
+ bottomConstraint
+ ])
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+}
diff --git a/ios/MullvadVPN/SettingsSwitchCell.swift b/ios/MullvadVPN/SettingsSwitchCell.swift
index 9911ff5d6a..a504726dff 100644
--- a/ios/MullvadVPN/SettingsSwitchCell.swift
+++ b/ios/MullvadVPN/SettingsSwitchCell.swift
@@ -21,8 +21,6 @@ class SettingsSwitchCell: SettingsCell {
switchContainer.control.addTarget(self, action: #selector(switchValueDidChange), for: .valueChanged)
- // Use UISwitch traits to make the entire cell behave as "Switch button"
- accessibilityTraits = switchContainer.control.accessibilityTraits
isAccessibilityElement = true
}
@@ -30,10 +28,20 @@ class SettingsSwitchCell: SettingsCell {
fatalError("init(coder:) has not been implemented")
}
+ func setEnabled(_ isEnabled: Bool) {
+ switchContainer.isEnabled = isEnabled
+ }
+
func setOn(_ isOn: Bool, animated: Bool) {
switchContainer.control.setOn(isOn, animated: animated)
}
+ override func prepareForReuse() {
+ super.prepareForReuse()
+
+ setEnabled(true)
+ }
+
// MARK: - Actions
@objc private func switchValueDidChange() {
@@ -42,6 +50,16 @@ class SettingsSwitchCell: SettingsCell {
// MARK: - Accessibility
+ override var accessibilityTraits: UIAccessibilityTraits {
+ set {
+ super.accessibilityTraits = newValue
+ }
+ get {
+ // Use UISwitch traits to make the entire cell behave as "Switch button"
+ return switchContainer.control.accessibilityTraits
+ }
+ }
+
override var accessibilityLabel: String? {
set {
super.accessibilityLabel = newValue
@@ -79,6 +97,8 @@ class SettingsSwitchCell: SettingsCell {
}
override func accessibilityActivate() -> Bool {
+ guard switchContainer.isEnabled else { return false }
+
let newValue = !self.switchContainer.control.isOn
setOn(newValue, animated: true)
diff --git a/ios/MullvadVPN/TunnelManager/TunnelManager.swift b/ios/MullvadVPN/TunnelManager/TunnelManager.swift
index c39232e942..1a7444d027 100644
--- a/ios/MullvadVPN/TunnelManager/TunnelManager.swift
+++ b/ios/MullvadVPN/TunnelManager/TunnelManager.swift
@@ -205,12 +205,12 @@ class TunnelManager {
.mapError { error in
return .loadAllVPNConfigurations(error)
}.mapThen { tunnels in
- return Result.Promise { resolver in
- self.initializeManager(accountToken: accountToken, tunnels: tunnels) { result in
+ return self.initializeManager(accountToken: accountToken, tunnels: tunnels)
+ .then { result -> Result<(), TunnelManager.Error> in
self.updatePrivateKeyRotationTimer()
- resolver.resolve(value: result)
+
+ return result
}
- }
}
.schedule(on: stateQueue)
.run(on: operationQueue, categories: [OperationCategory.manageTunnelProvider, OperationCategory.changeTunnelSettings])
@@ -531,15 +531,14 @@ class TunnelManager {
}
}
- private func initializeManager(accountToken: String?, tunnels: [TunnelProviderManagerType]?, completionHandler: @escaping (Result<(), Error>) -> Void) {
+ private func initializeManager(accountToken: String?, tunnels: [TunnelProviderManagerType]?) -> Result<(), TunnelManager.Error>.Promise {
// Migrate the tunnel settings if needed
let migrationResult = accountToken.map { self.migrateTunnelSettings(accountToken: $0) }
switch migrationResult {
case .success, .none:
break
case .failure(let migrationError):
- completionHandler(.failure(migrationError))
- return
+ return .failure(migrationError)
}
switch (tunnels?.first, accountToken) {
@@ -555,7 +554,7 @@ class TunnelManager {
self.tunnelInfo = TunnelInfo(token: accountToken, tunnelSettings: keychainEntry.tunnelSettings)
self.setTunnelProvider(tunnelProvider: tunnelProvider)
- completionHandler(.success(()))
+ return .success(())
// Remove the tunnel when failed to verify it but successfuly loaded the tunnel
// settings.
@@ -568,7 +567,7 @@ class TunnelManager {
// Remove the tunnel with corrupt configuration.
// It will be re-created upon the first attempt to connect the tunnel.
case (.success(false), .success(let keychainEntry)):
- tunnelProvider.removeFromPreferences()
+ return tunnelProvider.removeFromPreferences()
.receive(on: self.stateQueue)
.mapError { error in
return .removeInconsistentVPNConfiguration(error)
@@ -576,51 +575,42 @@ class TunnelManager {
.onSuccess { _ in
self.tunnelInfo = TunnelInfo(token: accountToken, tunnelSettings: keychainEntry.tunnelSettings)
}
- .observe { completion in
- completionHandler(completion.unwrappedValue!)
- }
// Remove the tunnel when failed to verify the tunnel and load tunnel settings.
case (.failure(let verificationError), .failure(_)):
self.logger.error(chainedError: verificationError, message: "Failed to verify the tunnel and load tunnel settings. Removing the tunnel.")
- tunnelProvider.removeFromPreferences()
+ return tunnelProvider.removeFromPreferences()
+ .receive(on: self.stateQueue)
.mapError { error in
return .removeInconsistentVPNConfiguration(error)
}
.flatMap { _ in
return .failure(verificationError)
}
- .observe { completion in
- completionHandler(completion.unwrappedValue!)
- }
// Remove the tunnel when the app is not able to read tunnel settings
case (.success(_), .failure(let settingsReadError)):
self.logger.error(chainedError: settingsReadError, message: "Failed to load tunnel settings. Removing the tunnel.")
- tunnelProvider.removeFromPreferences()
+ return tunnelProvider.removeFromPreferences()
+ .receive(on: self.stateQueue)
.mapError { error in
return .removeInconsistentVPNConfiguration(error)
}
.flatMap { _ in
return .failure(settingsReadError)
}
- .observe { completion in
- completionHandler(completion.unwrappedValue!)
- }
}
// Case 2: tunnel exists but account token is unset.
// Remove the orphaned tunnel.
case (.some(let tunnelProvider), .none):
- tunnelProvider.removeFromPreferences()
+ return tunnelProvider.removeFromPreferences()
+ .receive(on: self.stateQueue)
.mapError { error in
return .removeInconsistentVPNConfiguration(error)
}
- .observe { completion in
- completionHandler(completion.unwrappedValue!)
- }
// Case 3: tunnel does not exist but the account token is set.
// Verify that tunnel settings exists in keychain.
@@ -629,15 +619,15 @@ class TunnelManager {
case .success(let keychainEntry):
self.tunnelInfo = TunnelInfo(token: accountToken, tunnelSettings: keychainEntry.tunnelSettings)
- completionHandler(.success(()))
+ return .success(())
case .failure(let error):
- completionHandler(.failure(error))
+ return .failure(error)
}
// Case 4: no tunnels exist and account token is unset.
case (.none, .none):
- completionHandler(.success(()))
+ return .success(())
}
}
diff --git a/ios/MullvadVPN/TunnelSettings.swift b/ios/MullvadVPN/TunnelSettings.swift
index c8503c894d..fcbdebbe81 100644
--- a/ios/MullvadVPN/TunnelSettings.swift
+++ b/ios/MullvadVPN/TunnelSettings.swift
@@ -58,9 +58,53 @@ struct TunnelSettings: Codable, Equatable {
/// A struct that holds DNS settings.
struct DNSSettings: Codable, Equatable {
+ /// Maximum number of allowed DNS domains.
+ static let maxAllowedCustomDNSDomains = 3
+
/// Block advertising.
var blockAdvertising: Bool = false
/// Block tracking.
var blockTracking: Bool = false
+
+ /// Enable custom DNS.
+ var enableCustomDNS: Bool = false
+
+ /// Custom DNS domains.
+ var customDNSDomains: [AnyIPAddress] = []
+
+ /// Effective state of the custom DNS setting.
+ var effectiveEnableCustomDNS: Bool {
+ return !blockAdvertising && !blockTracking && enableCustomDNS && !customDNSDomains.isEmpty
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case blockAdvertising, blockTracking, enableCustomDNS, customDNSDomains
+ }
+
+ init() {}
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+
+ blockAdvertising = try container.decode(Bool.self, forKey: .blockAdvertising)
+ blockTracking = try container.decode(Bool.self, forKey: .blockTracking)
+
+ if let storedEnableCustomDNS = try container.decodeIfPresent(Bool.self, forKey: .enableCustomDNS) {
+ enableCustomDNS = storedEnableCustomDNS
+ }
+
+ if let storedCustomDNSDomains = try container.decodeIfPresent([AnyIPAddress].self, forKey: .customDNSDomains) {
+ customDNSDomains = storedCustomDNSDomains
+ }
+ }
+
+ func encode(to encoder: Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+
+ try container.encode(blockAdvertising, forKey: .blockAdvertising)
+ try container.encode(blockTracking, forKey: .blockTracking)
+ try container.encode(enableCustomDNS, forKey: .enableCustomDNS)
+ try container.encode(customDNSDomains, forKey: .customDNSDomains)
+ }
}
diff --git a/ios/MullvadVPN/UIColor+Palette.swift b/ios/MullvadVPN/UIColor+Palette.swift
index 8b442c32ca..3dab013297 100644
--- a/ios/MullvadVPN/UIColor+Palette.swift
+++ b/ios/MullvadVPN/UIColor+Palette.swift
@@ -32,7 +32,9 @@ extension UIColor {
enum TextField {
static let placeholderTextColor = UIColor(red: 0.16, green: 0.30, blue: 0.45, alpha: 0.40)
- static let textColor = UIColor(red: 0.16, green: 0.30, blue: 0.45, alpha: 1.00)
+ static let textColor = UIColor(red: 0.16, green: 0.30, blue: 0.45, alpha: 1.0)
+ static let backgroundColor = UIColor.white
+ static let invalidInputTextColor = UIColor.dangerColor
}
enum AppButton {
diff --git a/ios/MullvadVPN/UIMetrics.swift b/ios/MullvadVPN/UIMetrics.swift
index 9f60a35278..1ecfb8c4c1 100644
--- a/ios/MullvadVPN/UIMetrics.swift
+++ b/ios/MullvadVPN/UIMetrics.swift
@@ -15,6 +15,15 @@ extension UIMetrics {
/// Common layout margins for content presentation
static var contentLayoutMargins = UIEdgeInsets(top: 24, left: 24, bottom: 24, right: 24)
+ /// Common layout margins for settings cell presentation
+ static var settingsCellLayoutMargins = UIEdgeInsets(top: 16, left: 24, bottom: 16, right: 12)
+
+ /// Common layout margins for location cell presentation
+ static var selectLocationCellLayoutMargins = UIEdgeInsets(top: 16, left: 28, bottom: 16, right: 12)
+
+ /// Common cell indentation width
+ static var cellIndentationWidth: CGFloat = 16
+
/// Layout margins for in-app notification banner presentation
static var inAppBannerNotificationLayoutMargins = UIEdgeInsets(top: 16, left: 24, bottom: 16, right: 24)
diff --git a/ios/MullvadVPN/en.lproj/Preferences.strings b/ios/MullvadVPN/en.lproj/Preferences.strings
index 7f70361f6c..74e87385d1 100644
--- a/ios/MullvadVPN/en.lproj/Preferences.strings
+++ b/ios/MullvadVPN/en.lproj/Preferences.strings
@@ -1,8 +1,26 @@
/* No comment provided by engineer. */
+"ADD_CUSTOM_DNS_SERVER_CELL_LABEL" = "Add a server";
+
+/* No comment provided by engineer. */
"BLOCK_ADS_CELL_LABEL" = "Block ads";
/* No comment provided by engineer. */
"BLOCK_TRACKERS_CELL_LABEL" = "Block trackers";
+/* No comment provided by engineer. */
+"CUSTOM_DNS_CELL_LABEL" = "Use custom DNS";
+
+/* No comment provided by engineer. */
+"CUSTOM_DNS_DISABLE_ADTRACKER_BLOCKING_ACCESSIBILITY_HINT" = "Disable Block Ads and Block trackers to activate this setting.";
+
+/* No comment provided by engineer. */
+"CUSTOM_DNS_DISABLE_ADTRACKER_BLOCKING_FOOTNOTE" = "Disable Block Ads and Block trackers to activate this setting.";
+
+/* No comment provided by engineer. */
+"CUSTOM_DNS_NO_DNS_ENTRIES_ACCESSIBILITY_HINT" = "Please add at least one DNS domain to activate this setting.";
+
+/* No comment provided by engineer. */
+"CUSTOM_DNS_NO_DNS_ENTRIES_FOOTNOTE" = "Please add at least one DNS domain to activate this setting.";
+
/* Navigation title */
"NAVIGATION_TITLE" = "Preferences";
diff --git a/ios/MullvadVPN/en.lproj/Settings.strings b/ios/MullvadVPN/en.lproj/Settings.strings
index b221be2c46..ccc9fa8df3 100644
--- a/ios/MullvadVPN/en.lproj/Settings.strings
+++ b/ios/MullvadVPN/en.lproj/Settings.strings
@@ -10,6 +10,9 @@
/* No comment provided by engineer. */
"APP_VERSION_CELL_LABEL" = "App version";
+/* No comment provided by engineer. */
+"DNS_TEXT_CELL_PLACEHOLDER" = "Enter IP";
+
/* Navigation title */
"NAVIGATION_TITLE" = "Settings";
diff --git a/ios/MullvadVPNTests/DataSourceSnapshotTests.swift b/ios/MullvadVPNTests/DataSourceSnapshotTests.swift
new file mode 100644
index 0000000000..1d626e1688
--- /dev/null
+++ b/ios/MullvadVPNTests/DataSourceSnapshotTests.swift
@@ -0,0 +1,147 @@
+//
+// DataSourceSnapshotTests.swift
+// MullvadVPNTests
+//
+// Created by pronebird on 25/10/2021.
+// Copyright © 2021 Mullvad VPN AB. All rights reserved.
+//
+
+import XCTest
+
+class DataSourceSnapshotTests: XCTestCase {
+
+ func testInsertingItem() throws {
+ var a = DataSourceSnapshot<String, Int>()
+ var b = DataSourceSnapshot<String, Int>()
+
+ a.appendSections(["First"])
+ b.appendSections(["First"])
+
+ a.appendItems([1, 3], in: "First")
+ b.appendItems([1, 2, 3], in: "First")
+
+ let diff = a.difference(b)
+
+ XCTAssertEqual(diff.indexPathsToDelete, [])
+ XCTAssertEqual(diff.indexPathsToInsert, [IndexPath(row: 1, section: 0)])
+ }
+
+ func testRemovingItem() throws {
+ var a = DataSourceSnapshot<String, Int>()
+ var b = DataSourceSnapshot<String, Int>()
+
+ a.appendSections(["First"])
+ b.appendSections(["First"])
+
+ a.appendItems([1, 2, 3], in: "First")
+ b.appendItems([1, 3], in: "First")
+
+ let diff = a.difference(b)
+
+ XCTAssertEqual(diff.indexPathsToDelete, [IndexPath(row: 1, section: 0)])
+ XCTAssertEqual(diff.indexPathsToInsert, [])
+ }
+
+ func testMovingItemWithinSection() throws {
+ var a = DataSourceSnapshot<String, Int>()
+ var b = DataSourceSnapshot<String, Int>()
+
+ a.appendSections(["First"])
+ b.appendSections(["First"])
+
+ a.appendItems([1, 2, 3], in: "First")
+ b.appendItems([2, 1, 3], in: "First")
+
+ let diff = a.difference(b)
+
+ XCTAssertEqual(diff.indexPathsToDelete, [IndexPath(row: 0, section: 0)])
+ XCTAssertEqual(diff.indexPathsToInsert, [IndexPath(row: 1, section: 0)])
+ }
+
+ func testMovingItemBetweenSections() throws {
+ var a = DataSourceSnapshot<String, Int>()
+ var b = DataSourceSnapshot<String, Int>()
+
+ a.appendSections(["First", "Second"])
+ b.appendSections(["First", "Second"])
+
+ a.appendItems([1, 2, 3 ,4], in: "First")
+ a.appendItems([5, 6, 7, 8], in: "Second")
+
+ b.appendItems([5, 1, 2, 8, 4], in: "First")
+ b.appendItems([6, 3, 7], in: "Second")
+
+ let diff = a.difference(b)
+
+ XCTAssertEqual(diff.indexPathsToDelete, [
+ IndexPath(row: 3, section: 1),
+ IndexPath(row: 0, section: 1),
+ IndexPath(row: 2, section: 0)
+ ])
+
+ XCTAssertEqual(diff.indexPathsToInsert, [
+ IndexPath(row: 0, section: 0),
+ IndexPath(row: 3, section: 0),
+ IndexPath(row: 1, section: 1)
+ ])
+ }
+
+ func testSwappingItems() throws {
+ var a = DataSourceSnapshot<String, Int>()
+ var b = DataSourceSnapshot<String, Int>()
+
+ a.appendSections(["First"])
+ b.appendSections(["First"])
+
+ a.appendItems([1, 2, 3], in: "First")
+ b.appendItems([3, 2, 1], in: "First")
+
+ let diff = a.difference(b)
+
+ XCTAssertEqual(diff.indexPathsToDelete, [
+ IndexPath(row: 2, section: 0),
+ IndexPath(row: 0, section: 0)
+ ])
+
+ XCTAssertEqual(diff.indexPathsToInsert, [
+ IndexPath(row: 0, section: 0),
+ IndexPath(row: 2, section: 0)
+ ])
+ }
+
+ func testShiftingItems() throws {
+ var a = DataSourceSnapshot<String, Int>()
+ var b = DataSourceSnapshot<String, Int>()
+
+ a.appendSections(["First"])
+ b.appendSections(["First"])
+
+ a.appendItems([1, 2, 3, 4], in: "First")
+ b.appendItems([1, 3, 4, 5], in: "First")
+
+ let diff = a.difference(b)
+
+ XCTAssertEqual(diff.indexPathsToDelete, [IndexPath(row: 1, section: 0)])
+ XCTAssertEqual(diff.indexPathsToInsert, [IndexPath(row: 3, section: 0)])
+ }
+
+ func testReloadingAndReconfiguringItems() throws {
+ var a = DataSourceSnapshot<String, Int>()
+ var b = DataSourceSnapshot<String, Int>()
+
+ a.appendSections(["First"])
+ b.appendSections(["First"])
+
+ a.appendItems([1, 2], in: "First")
+ b.appendItems([1, 2], in: "First")
+
+ b.reloadItems([1])
+ b.reconfigureItems([2])
+
+ let diff = a.difference(b)
+
+ XCTAssertEqual(diff.indexPathsToReload, [IndexPath(row: 0, section: 0)])
+ XCTAssertEqual(diff.indexPathsToReconfigure, [IndexPath(row: 1, section: 0)])
+ }
+
+}
diff --git a/ios/PacketTunnel/PacketTunnelProvider.swift b/ios/PacketTunnel/PacketTunnelProvider.swift
index 8bd55358b8..cff2438130 100644
--- a/ios/PacketTunnel/PacketTunnelProvider.swift
+++ b/ios/PacketTunnel/PacketTunnelProvider.swift
@@ -292,7 +292,6 @@ struct PacketTunnelConfiguration {
}
extension PacketTunnelConfiguration {
-
var wgTunnelConfig: TunnelConfiguration {
let mullvadEndpoint = selectorResult.endpoint
var peers = [mullvadEndpoint.ipv4RelayEndpoint]
@@ -323,15 +322,21 @@ extension PacketTunnelConfiguration {
let mullvadEndpoint = selectorResult.endpoint
let dnsSettings = tunnelSettings.interface.dnsSettings
- switch (dnsSettings.blockAdvertising, dnsSettings.blockTracking) {
- case (true, false):
- return [IPv4Address("100.64.0.1")!]
- case (false, true):
- return [IPv4Address("100.64.0.2")!]
- case (true, true):
- return [IPv4Address("100.64.0.3")!]
- case (false, false):
- return [mullvadEndpoint.ipv4Gateway, mullvadEndpoint.ipv6Gateway]
+ if dnsSettings.effectiveEnableCustomDNS {
+ let dnsServers = dnsSettings.customDNSDomains
+ .prefix(DNSSettings.maxAllowedCustomDNSDomains)
+ return Array(dnsServers)
+ } else {
+ switch (dnsSettings.blockAdvertising, dnsSettings.blockTracking) {
+ case (true, false):
+ return [IPv4Address("100.64.0.1")!]
+ case (false, true):
+ return [IPv4Address("100.64.0.2")!]
+ case (true, true):
+ return [IPv4Address("100.64.0.3")!]
+ case (false, false):
+ return [mullvadEndpoint.ipv4Gateway, mullvadEndpoint.ipv6Gateway]
+ }
}
}
}