diff options
| author | Andrej Mihajlov <and@mullvad.net> | 2021-11-03 13:35:04 +0100 |
|---|---|---|
| committer | Andrej Mihajlov <and@mullvad.net> | 2021-11-03 13:35:04 +0100 |
| commit | ec21ef1a55b72182d2d05fc0a04c6d405934df62 (patch) | |
| tree | d5860757d53c956060d51f4436c4a85fd2a8fadd | |
| parent | 7ec35a8a320b601305b9f2d796368d785faf58f8 (diff) | |
| parent | e2c608bf069f9f53de8505c2744bbd4bfee9cc8d (diff) | |
| download | mullvadvpn-ec21ef1a55b72182d2d05fc0a04c6d405934df62.tar.xz mullvadvpn-ec21ef1a55b72182d2d05fc0a04c6d405934df62.zip | |
Merge branch 'custom-dns-ios'
29 files changed, 2048 insertions, 258 deletions
@@ -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] + } } } } |
