diff --git a/Core/FeatureFlag.swift b/Core/FeatureFlag.swift index d968072d0b..b642643075 100644 --- a/Core/FeatureFlag.swift +++ b/Core/FeatureFlag.swift @@ -78,6 +78,8 @@ public enum FeatureFlag: String { /// Feature flag to enable / disable phishing and malware protection /// https://app.asana.com/0/1206329551987282/1207149365636877/f case maliciousSiteProtection + + case networkProtectionRiskyDomainsProtection } extension FeatureFlag: FeatureFlagDescribing { @@ -100,6 +102,8 @@ extension FeatureFlag: FeatureFlagDescribing { return true case .testExperiment: return true + case .networkProtectionRiskyDomainsProtection: + return true default: return false } @@ -185,6 +189,8 @@ extension FeatureFlag: FeatureFlagDescribing { return .remoteReleasable(.subfeature(ExperimentTestSubfeatures.experimentTestAA)) case .maliciousSiteProtection: return .remoteReleasable(.subfeature(MaliciousSiteProtectionSubfeature.onByDefault)) + case .networkProtectionRiskyDomainsProtection: + return .remoteReleasable(.subfeature(NetworkProtectionSubfeature.riskyDomainsProtection)) } } } diff --git a/DuckDuckGo-iOS.xcodeproj/project.pbxproj b/DuckDuckGo-iOS.xcodeproj/project.pbxproj index 3ec074c097..130fa1b49f 100644 --- a/DuckDuckGo-iOS.xcodeproj/project.pbxproj +++ b/DuckDuckGo-iOS.xcodeproj/project.pbxproj @@ -313,9 +313,11 @@ 569437362BE5160600C0881B /* SyncSettingsViewControllerErrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 569437352BE5160600C0881B /* SyncSettingsViewControllerErrorTests.swift */; }; 56A061442BEE086700F24B36 /* CapturingAdapterErrorHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 569437262BDD467400C0881B /* CapturingAdapterErrorHandler.swift */; }; 56A061452BEE086E00F24B36 /* CapturingSyncPausedStateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 569437372BE530D300C0881B /* CapturingSyncPausedStateManager.swift */; }; + 56CCBA2E2D5CF07D00EA0EB5 /* NetworkProtectionUIElements.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56CCBA2D2D5CF07D00EA0EB5 /* NetworkProtectionUIElements.swift */; }; 56D060262C359D2E003BAEB5 /* ContextualOnboardingDialogs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56D060252C359D2E003BAEB5 /* ContextualOnboardingDialogs.swift */; }; 56D060282C380D83003BAEB5 /* OnboardingSuggestedSearchesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56D060272C380D83003BAEB5 /* OnboardingSuggestedSearchesProvider.swift */; }; 56D0602D2C383FD2003BAEB5 /* OnboardingSuggestedSearchesProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56D0602C2C383FD2003BAEB5 /* OnboardingSuggestedSearchesProviderTests.swift */; }; + 56D2D65C2D5F748200C59354 /* NetworkProtectionDNSSettingsViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56D2D65B2D5F748200C59354 /* NetworkProtectionDNSSettingsViewModelTests.swift */; }; 56D7792C2CFF476800B619EF /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D664C7DC2B28A02800CBFA76 /* StoreKit.framework */; }; 56D7793A2CFFC7E800B619EF /* PixelExperimentKit in Frameworks */ = {isa = PBXBuildFile; productRef = 56D779392CFFC7E800B619EF /* PixelExperimentKit */; }; 56D7793C2CFFC7E800B619EF /* PixelKit in Frameworks */ = {isa = PBXBuildFile; productRef = 56D7793B2CFFC7E800B619EF /* PixelKit */; }; @@ -1790,9 +1792,11 @@ 569437322BE4E3DD00C0881B /* SyncErrorHandlerSyncErrorsAlertsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncErrorHandlerSyncErrorsAlertsTests.swift; sourceTree = ""; }; 569437352BE5160600C0881B /* SyncSettingsViewControllerErrorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncSettingsViewControllerErrorTests.swift; sourceTree = ""; }; 569437372BE530D300C0881B /* CapturingSyncPausedStateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapturingSyncPausedStateManager.swift; sourceTree = ""; }; + 56CCBA2D2D5CF07D00EA0EB5 /* NetworkProtectionUIElements.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkProtectionUIElements.swift; sourceTree = ""; }; 56D060252C359D2E003BAEB5 /* ContextualOnboardingDialogs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextualOnboardingDialogs.swift; sourceTree = ""; }; 56D060272C380D83003BAEB5 /* OnboardingSuggestedSearchesProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingSuggestedSearchesProvider.swift; sourceTree = ""; }; 56D0602C2C383FD2003BAEB5 /* OnboardingSuggestedSearchesProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingSuggestedSearchesProviderTests.swift; sourceTree = ""; }; + 56D2D65B2D5F748200C59354 /* NetworkProtectionDNSSettingsViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkProtectionDNSSettingsViewModelTests.swift; sourceTree = ""; }; 56D855692BEA9169009F9698 /* CurrentDateProviding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentDateProviding.swift; sourceTree = ""; }; 56D8556B2BEA91C4009F9698 /* SyncAlertsPresenting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncAlertsPresenting.swift; sourceTree = ""; }; 653561012D4D2C680064F258 /* Logger+DuckPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Logger+DuckPlayer.swift"; sourceTree = ""; }; @@ -5314,6 +5318,7 @@ children = ( 981FED682201FE69008488D7 /* AutoClearSettingsScreenTests.swift */, 8598F6792405EB8600FBC70C /* KeyboardSettingsTests.swift */, + 56D2D65B2D5F748200C59354 /* NetworkProtectionDNSSettingsViewModelTests.swift */, ); name = Settings; sourceTree = ""; @@ -6418,6 +6423,7 @@ 4B0F3F4F2B9BFF2100392892 /* NetworkProtectionFAQView.swift */, BD2F39EA2C19F955005B19E7 /* NetworkProtectionDNSSettingsView.swift */, BDF8D0012C1B87F4003E3B27 /* NetworkProtectionDNSSettingsViewModel.swift */, + 56CCBA2D2D5CF07D00EA0EB5 /* NetworkProtectionUIElements.swift */, ); name = VPNSettings; sourceTree = ""; @@ -8560,6 +8566,7 @@ C1935A122C88D1D8001AD72D /* AutofillHeaderViewFactory.swift in Sources */, 31B2F11F287846320040427A /* NoMicPermissionAlert.swift in Sources */, 310C4B45281B5A9A00BA79A9 /* AutofillLoginDetailsView.swift in Sources */, + 56CCBA2E2D5CF07D00EA0EB5 /* NetworkProtectionUIElements.swift in Sources */, 6F9FFE2D2C57AE8F00A238BE /* NewTabPageShortcutsSettingsModel.swift in Sources */, CBF2597D2D41734700AC63E4 /* Terminating.swift in Sources */, D62EC3C22C248AF800FC9D04 /* DuckPlayerNavigationHandling.swift in Sources */, @@ -9070,6 +9077,7 @@ C14882EA27F20DD000D59F0C /* MockBookmarksCoreDataStorage.swift in Sources */, 6594CCEA2D4A3E1200188B1A /* DuckPlayerSettingsTests.swift in Sources */, 1E05D1DB29C47B3300BF9A1F /* DailyPixelTests.swift in Sources */, + 56D2D65C2D5F748200C59354 /* NetworkProtectionDNSSettingsViewModelTests.swift in Sources */, 564DE4552C3EDEF200D23241 /* ContextualOnboardingNewTabDialogFactoryTests.swift in Sources */, 6F5AA3EF2CC1588400685CB4 /* FavoritesListInteractingAdapterTests.swift in Sources */, 981FED7422046017008488D7 /* AutoClearTests.swift in Sources */, @@ -12363,8 +12371,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/DuckDuckGo/BrowserServicesKit.git"; requirement = { - kind = exactVersion; - version = 237.1.0; + branch = "sabrina/risky-sites-protection"; + kind = branch; }; }; 9F8FE9472BAE50E50071E372 /* XCRemoteSwiftPackageReference "lottie-spm" */ = { diff --git a/DuckDuckGo-iOS.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DuckDuckGo-iOS.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 20dafe787f..83f7be8847 100644 --- a/DuckDuckGo-iOS.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DuckDuckGo-iOS.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/DuckDuckGo/BrowserServicesKit.git", "state" : { - "revision" : "92f57bfcf15258a360f6df8a48da756491683fe0", - "version" : "237.1.0" + "branch" : "sabrina/risky-sites-protection", + "revision" : "c933d36a84b488150eedce6425ed8c3f5c38ba1f" } }, { @@ -59,8 +59,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/duckduckgo/duckduckgo-autofill.git", "state" : { - "revision" : "676d42679296a175169e433f2332e55644151edd", - "version" : "16.2.0" + "revision" : "3a9606fd26e9a54bf369cc241e6fa3b2571eb13a", + "version" : "16.2.1" } }, { @@ -131,8 +131,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/duckduckgo/privacy-dashboard", "state" : { - "revision" : "099f7ed5faac946e4d80746703aaaf87fdfbee09", - "version" : "8.3.0" + "revision" : "c05d7ce7debe45593bfd9759d6bcae0cd740c52f", + "version" : "8.4.0" } }, { diff --git a/DuckDuckGo/NetworkProtectionDNSSettingsView.swift b/DuckDuckGo/NetworkProtectionDNSSettingsView.swift index 33698a2a83..e35db4aaf3 100644 --- a/DuckDuckGo/NetworkProtectionDNSSettingsView.swift +++ b/DuckDuckGo/NetworkProtectionDNSSettingsView.swift @@ -21,7 +21,7 @@ import SwiftUI import NetworkProtection struct NetworkProtectionDNSSettingsView: View { - @StateObject var viewModel = NetworkProtectionDNSSettingsViewModel(settings: VPNSettings(defaults: .networkProtectionGroupDefaults)) + @StateObject var viewModel = NetworkProtectionDNSSettingsViewModel(settings: VPNSettings(defaults: .networkProtectionGroupDefaults), controller: AppDependencyProvider.shared.networkProtectionTunnelController) @Environment(\.dismiss) private var dismiss @FocusState private var isCustomDNSServerFocused: Bool @@ -29,14 +29,14 @@ struct NetworkProtectionDNSSettingsView: View { VStack { List { Section { - ChecklistItem(isSelected: !viewModel.isCustomDNSSelected) { + NetworkProtectionUIElements.ChecklistItem(isSelected: !viewModel.isCustomDNSSelected) { viewModel.toggleDNSSettings() } label: { Text(UserText.vpnSettingDNSServerOptionRecommended) .daxBodyRegular() .foregroundStyle(Color(designSystemColor: .textPrimary)) } - ChecklistItem(isSelected: viewModel.isCustomDNSSelected) { + NetworkProtectionUIElements.ChecklistItem(isSelected: viewModel.isCustomDNSSelected) { viewModel.toggleDNSSettings() } label: { Text(UserText.vpnSettingDNSServerOptionCustom) @@ -57,6 +57,10 @@ struct NetworkProtectionDNSSettingsView: View { if viewModel.isCustomDNSSelected { customDNSSection() + } else { + if viewModel.isRiskySitesProtectionFeatureEnabled { + blockRiskyDomainsSection() + } } } } @@ -105,29 +109,15 @@ struct NetworkProtectionDNSSettingsView: View { isCustomDNSServerFocused = true } } -} -private struct ChecklistItem: View where Content: View { - let isSelected: Bool - let action: () -> Void - @ViewBuilder let label: () -> Content - - var body: some View { - Button( - action: action, - label: { - HStack(spacing: 12) { - label() - Spacer() - Image(systemName: "checkmark") - .tint(.init(designSystemColor: .accent)) - .if(!isSelected) { - $0.hidden() - } - } - } - ) - .tint(Color(designSystemColor: .textPrimary)) - .listRowInsets(EdgeInsets(top: 14, leading: 16, bottom: 14, trailing: 16)) + func blockRiskyDomainsSection() -> some View { + NetworkProtectionUIElements.ToggleSectionView( + text: "Block risky domains", + headerText: "Content Blocking and Filtering", + footerText: UserText.vpnContentBlockingFilteringFooter + ) { + Toggle("", isOn: $viewModel.isBlockRiskyDomainsOn) + } } + } diff --git a/DuckDuckGo/NetworkProtectionDNSSettingsViewModel.swift b/DuckDuckGo/NetworkProtectionDNSSettingsViewModel.swift index 429a9c0bf6..83e8691153 100644 --- a/DuckDuckGo/NetworkProtectionDNSSettingsViewModel.swift +++ b/DuckDuckGo/NetworkProtectionDNSSettingsViewModel.swift @@ -21,41 +21,67 @@ import Foundation import Combine import NetworkProtection import Core +import BrowserServicesKit final class NetworkProtectionDNSSettingsViewModel: ObservableObject { private let settings: VPNSettings + private let controller: TunnelController + private let featureFlagger: FeatureFlagger private var cancellables: Set = [] - @Published public var dnsSettings: NetworkProtectionDNSSettings = .default + @Published public var dnsSettings: NetworkProtectionDNSSettings - @Published public var isCustomDNSSelected = false - - @Published public var customDNSServers = "" + @Published public var isCustomDNSSelected: Bool + + @Published var isBlockRiskyDomainsOn: Bool { + didSet { + applyDNSSettings() + } + } + + @Published public var customDNSServers: String @Published public var isApplyButtonEnabled = false - init(settings: VPNSettings) { + var isRiskySitesProtectionFeatureEnabled: Bool { + featureFlagger.isFeatureOn(.networkProtectionRiskyDomainsProtection) + } + + init(settings: VPNSettings, controller: TunnelController, featureFlagger: FeatureFlagger = AppDependencyProvider.shared.featureFlagger) { self.settings = settings + self.controller = controller + self.featureFlagger = featureFlagger + + dnsSettings = settings.dnsSettings + isBlockRiskyDomainsOn = settings.isBlockRiskyDomainsOn + isCustomDNSSelected = settings.dnsSettings.usesCustomDNS + customDNSServers = settings.customDnsServers.joined(separator: ", ") + subscribeToDNSSettingsChanges() + } + + func subscribeToDNSSettingsChanges() { settings.dnsSettingsPublisher .receive(on: DispatchQueue.main) .assign(to: \.dnsSettings, onWeaklyHeld: self) .store(in: &cancellables) - - isCustomDNSSelected = settings.dnsSettings.usesCustomDNS - customDNSServers = settings.dnsSettings.dnsServersText } func toggleDNSSettings() { isCustomDNSSelected.toggle() } + func toggleIsBlockRiskyDomainsOn() { + isBlockRiskyDomainsOn.toggle() + } + func applyDNSSettings() { if isCustomDNSSelected { settings.dnsSettings = .custom([customDNSServers]) } else { - settings.dnsSettings = .default + settings.dnsSettings = .ddg(blockRiskyDomains: isBlockRiskyDomainsOn) } + reloadVPN() /// Updating `dnsSettings` does an IPv4 conversion before actually commiting the change, /// so we do a final check to see which outcome the user ends up with @@ -73,12 +99,20 @@ final class NetworkProtectionDNSSettingsViewModel: ObservableObject { isApplyButtonEnabled = true } } + + private func reloadVPN() { + Task { + // We need to allow some time for the setting to propagate + try await Task.sleep(interval: 0.1) + try await controller.command(.restartAdapter) + } + } } extension NetworkProtectionDNSSettings { fileprivate var dnsServersText: String { switch self { - case .default: return "" + case .ddg: return "" case .custom(let servers): return servers.joined(separator: ", ") } } diff --git a/DuckDuckGo/NetworkProtectionTunnelController.swift b/DuckDuckGo/NetworkProtectionTunnelController.swift index ed22d7cd91..b788a56757 100644 --- a/DuckDuckGo/NetworkProtectionTunnelController.swift +++ b/DuckDuckGo/NetworkProtectionTunnelController.swift @@ -292,7 +292,12 @@ final class NetworkProtectionTunnelController: TunnelController, TunnelSessionPr } options[NetworkProtectionOptionKey.selectedEnvironment] = AppDependencyProvider.shared.vpnSettings .selectedEnvironment.rawValue as NSString - if let data = try? JSONEncoder().encode(AppDependencyProvider.shared.vpnSettings.dnsSettings) { + + var dnsSettings = settings.dnsSettings + if dnsSettings == .ddg(blockRiskyDomains: true) && !featureFlagger.isFeatureOn(.networkProtectionRiskyDomainsProtection) { + dnsSettings = .ddg(blockRiskyDomains: false) + } + if let data = try? JSONEncoder().encode(dnsSettings) { options[NetworkProtectionOptionKey.dnsSettings] = NSData(data: data) } diff --git a/DuckDuckGo/NetworkProtectionUIElements.swift b/DuckDuckGo/NetworkProtectionUIElements.swift new file mode 100644 index 0000000000..9ec3b299c7 --- /dev/null +++ b/DuckDuckGo/NetworkProtectionUIElements.swift @@ -0,0 +1,112 @@ +// +// NetworkProtectionUIElements.swift +// DuckDuckGo +// +// Copyright © 2025 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import SwiftUI + +struct NetworkProtectionUIElements { + + struct ChecklistItem: View where Content: View { + let isSelected: Bool + let action: () -> Void + @ViewBuilder let label: () -> Content + + var body: some View { + Button( + action: action, + label: { + HStack(spacing: 12) { + label() + Spacer() + Image(systemName: "checkmark") + .tint(.init(designSystemColor: .accent)) + .if(!isSelected) { + $0.hidden() + } + } + } + ) + .tint(Color(designSystemColor: .textPrimary)) + .listRowInsets(EdgeInsets(top: 14, leading: 16, bottom: 14, trailing: 16)) + } + } + + struct ToggleSectionView: View { + let text: String + let headerText: String + let footerText: String + let toggle: Content + + init(text: String, headerText: String, footerText: String, @ViewBuilder toggle: () -> Content) { + self.text = text + self.headerText = headerText + self.footerText = footerText + self.toggle = toggle() + } + + var body: some View { + Section { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(text) + .daxBodyRegular() + .foregroundColor(.init(designSystemColor: .textPrimary)) + .layoutPriority(1) + } + + toggle + .toggleStyle(SwitchToggleStyle(tint: .init(designSystemColor: .accent))) + } + } header: { + Text(headerText) + } footer: { + Text(LocalizedStringKey(footerText)) + .foregroundColor(.init(designSystemColor: .textSecondary)) + .accentColor(Color(designSystemColor: .accent)) + .daxFootnoteRegular() + .padding(.top, 6) + } + .listRowBackground(Color(designSystemColor: .surface)) + } + } + + struct MenuItem: View { + let isSelected: Bool + let title: String + let action: () -> Void + + var body: some View { + Button( + action: action, + label: { + HStack(spacing: 12) { + Text(title).daxBodyRegular() + Spacer() + Image(systemName: "checkmark") + .if(!isSelected) { + $0.hidden() + } + .tint(Color(designSystemColor: .textPrimary)) + } + } + ) + .tint(Color(designSystemColor: .textPrimary)) + } + } +} diff --git a/DuckDuckGo/NetworkProtectionVPNLocationView.swift b/DuckDuckGo/NetworkProtectionVPNLocationView.swift index eb49cbdff8..3727b9aef2 100644 --- a/DuckDuckGo/NetworkProtectionVPNLocationView.swift +++ b/DuckDuckGo/NetworkProtectionVPNLocationView.swift @@ -41,7 +41,7 @@ struct NetworkProtectionVPNLocationView: View { @ViewBuilder private func nearest(isSelected: Bool) -> some View { Section { - ChecklistItem( + NetworkProtectionUIElements.ChecklistItem( isSelected: isSelected, action: { Task { @@ -106,7 +106,7 @@ private struct CountryItem: View { } var body: some View { - ChecklistItem( + NetworkProtectionUIElements.ChecklistItem( isSelected: itemModel.isSelected, action: action, label: { @@ -125,7 +125,7 @@ private struct CountryItem: View { Spacer() Menu { ForEach(itemModel.cityPickerItems) { cityItem in - MenuItem(isSelected: cityItem.isSelected, title: cityItem.name) { + NetworkProtectionUIElements.MenuItem(isSelected: cityItem.isSelected, title: cityItem.name) { cityPickerAction(cityItem.id) } } @@ -140,51 +140,3 @@ private struct CountryItem: View { ) } } - -private struct ChecklistItem: View where Content: View { - let isSelected: Bool - let action: () -> Void - @ViewBuilder let label: () -> Content - - var body: some View { - Button( - action: action, - label: { - HStack(spacing: 12) { - Image(systemName: "checkmark") - .tint(.init(designSystemColor: .accent)) - .if(!isSelected) { - $0.hidden() - } - label() - } - } - ) - .tint(Color(designSystemColor: .textPrimary)) - .listRowInsets(EdgeInsets(top: 14, leading: 16, bottom: 14, trailing: 16)) - } -} - -private struct MenuItem: View { - let isSelected: Bool - let title: String - let action: () -> Void - - var body: some View { - Button( - action: action, - label: { - HStack(spacing: 12) { - Text(title).daxBodyRegular() - Spacer() - Image(systemName: "checkmark") - .if(!isSelected) { - $0.hidden() - } - .tint(Color(designSystemColor: .textPrimary)) - } - } - ) - .tint(Color(designSystemColor: .textPrimary)) - } -} diff --git a/DuckDuckGo/UserText.swift b/DuckDuckGo/UserText.swift index 4ee19edc2c..ddc0842421 100644 --- a/DuckDuckGo/UserText.swift +++ b/DuckDuckGo/UserText.swift @@ -800,6 +800,7 @@ public struct UserText { public static let vpnSettingDNSServerIPv4Title = NSLocalizedString("vpn.settings.dns.server.ipv4.title", value: "IPv4 Address", comment: "Title for the IPv4 Address setting") public static let vpnSettingDNSServerScreenTitle = NSLocalizedString("vpn.settings.dns.server.screen.title", value: "DNS Server", comment: "Title for the DNS Server setting screen") public static let vpnSettingDNSServerApplyButtonTitle = NSLocalizedString("vpn.settings.dns.server.apply.button.title", value: "Apply", comment: "Title for the Apply button on the DNS Server setting screen") + public static let vpnContentBlockingFilteringFooter = NSLocalizedString("settings.web.tracking.protection.explanation", value: "Block 150,000+ domains flagged for hosting malware, phishing attacks, and online scams with a DNS-level blocklist. [Learn More](ddgQuickLink://duckduckgo.com/duckduckgo-help-pages/privacy-pro/vpn/dns-blocklists/)", comment: "Explanation in Settings how the vpn blocks risky sites (do not remove the link)") // MARK: Notifications diff --git a/DuckDuckGo/en.lproj/Localizable.strings b/DuckDuckGo/en.lproj/Localizable.strings index b93f926ffb..ee204ca6a4 100644 --- a/DuckDuckGo/en.lproj/Localizable.strings +++ b/DuckDuckGo/en.lproj/Localizable.strings @@ -2461,8 +2461,9 @@ But if you *do* want a peek under the hood, you can find more information about /* The name of Settings category in Privacy Features related to configuration of the web tracking protection feature */ "settings.web.tracking.protection" = "Web Tracking Protection"; -/* Explanation in Settings how the web tracking protection feature works */ -"settings.web.tracking.protection.explanation" = "DuckDuckGo automatically blocks hidden trackers as you browse the web.\n[Learn More](ddgQuickLink://duckduckgo.com/duckduckgo-help-pages/privacy/web-tracking-protections/)"; +/* Explanation in Settings how the vpn blocks risky sites (do not remove the link) + Explanation in Settings how the web tracking protection feature works */ +"settings.web.tracking.protection.explanation" = "Block 150,000+ domains flagged for hosting malware, phishing attacks, and online scams with a DNS-level blocklist. [Learn More](ddgQuickLink://duckduckgo.com/duckduckgo-help-pages/privacy-pro/vpn/dns-blocklists/)"; /* Description on a report broken site page. */ "site.not.working.description" = "Select the option that best describes the problem you experienced."; diff --git a/DuckDuckGoTests/NetworkProtectionDNSSettingsViewModelTests.swift b/DuckDuckGoTests/NetworkProtectionDNSSettingsViewModelTests.swift new file mode 100644 index 0000000000..c7790999c6 --- /dev/null +++ b/DuckDuckGoTests/NetworkProtectionDNSSettingsViewModelTests.swift @@ -0,0 +1,249 @@ +// +// NetworkProtectionDNSSettingsViewModelTests.swift +// DuckDuckGo +// +// Copyright © 2025 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +import NetworkProtection +@testable import DuckDuckGo + +final class NetworkProtectionDNSSettingsViewModelTests: XCTestCase { + + var model: NetworkProtectionDNSSettingsViewModel! + let userDefaults = UserDefaults(suiteName: "TestDefaults")! + var vpnSettings: VPNSettings! + + override func setUpWithError() throws { + vpnSettings = VPNSettings(defaults: userDefaults) + model = NetworkProtectionDNSSettingsViewModel(settings: vpnSettings, controller: MockTunnelController(), featureFlagger: MockFeatureFlagger()) + } + + override func tearDownWithError() throws { + userDefaults.removePersistentDomain(forName: "TestDefaults") + vpnSettings = nil + model = nil + } + + func testInitialState() { + XCTAssertEqual(model.dnsSettings, vpnSettings.dnsSettings) + XCTAssertFalse(model.isCustomDNSSelected) + XCTAssertEqual(model.customDNSServers, vpnSettings.customDnsServers.joined(separator: ", ")) + XCTAssertTrue(model.isBlockRiskyDomainsOn) + } + + func test_WhenUpdateDNSSettingsToCustomThenPropagatesToVpnSettings() { + // GIVEN + model.customDNSServers = "1.1.1.1" + model.isCustomDNSSelected = true + + // WHEN + model.applyDNSSettings() + + // THEN + switch vpnSettings.dnsSettings { + case .custom(let servers): + XCTAssertEqual(servers, ["1.1.1.1"], "Custom DNS servers should be updated correctly.") + default: + XCTFail("Expected dnsSettings to be .custom, but got \(vpnSettings.dnsSettings)") + } + } + + func test_WhenUpdateDNSSettingsToDefaultWithThenPropagatesToVpnSettings() { + // GIVEN + model.isCustomDNSSelected = false + model.isBlockRiskyDomainsOn = true + + // WHEN + model.applyDNSSettings() + + // THEN + switch vpnSettings.dnsSettings { + case .ddg(let blockRiskyDomains): + XCTAssertTrue(blockRiskyDomains, "Expected blockRiskyDomains to be false.") + default: + XCTFail("Expected dnsSettings to be .ddg, but got \(vpnSettings.dnsSettings)") + } + } + + func test_WhenUpdateDNSSettingsToDefaultWithBlockOffThenPropagatesToVpnSettings() { + // GIVEN + model.isCustomDNSSelected = false + model.isBlockRiskyDomainsOn = false + + // WHEN + model.applyDNSSettings() + + // THEN + switch vpnSettings.dnsSettings { + case .ddg(let blockRiskyDomains): + XCTAssertFalse(blockRiskyDomains, "Expected blockRiskyDomains to be false.") + default: + XCTFail("Expected dnsSettings to be .ddg, but got \(vpnSettings.dnsSettings)") + } + } + + func test_WhenMovingFromDefaultToCustomAndBackToDefaultThenBlockSettingRetainedToFalse() { + // GIVEN + model.isCustomDNSSelected = false + model.isBlockRiskyDomainsOn = false + model.applyDNSSettings() + model.isCustomDNSSelected = true + model.customDNSServers = "1.1.1.1" + model.applyDNSSettings() + + // WHEN + model.isCustomDNSSelected = false + model.applyDNSSettings() + + // THEN + switch vpnSettings.dnsSettings { + case .ddg(let blockRiskyDomains): + XCTAssertFalse(blockRiskyDomains, "Expected blockRiskyDomains to be false.") + default: + XCTFail("Expected dnsSettings to be .ddg, but got \(vpnSettings.dnsSettings)") + } + } + + func test_WhenMovingFromDefaultToCustomAndBackToDefaultThenBlockSettingRetainedToTrue() { + // GIVEN + model.isCustomDNSSelected = false + model.isBlockRiskyDomainsOn = true + model.applyDNSSettings() + model.isCustomDNSSelected = true + model.customDNSServers = "1.1.1.1" + model.applyDNSSettings() + + // WHEN + model.isCustomDNSSelected = false + model.applyDNSSettings() + + // THEN + switch vpnSettings.dnsSettings { + case .ddg(let blockRiskyDomains): + XCTAssertTrue(blockRiskyDomains, "Expected blockRiskyDomains to be true.") + default: + XCTFail("Expected dnsSettings to be .ddg, but got \(vpnSettings.dnsSettings)") + } + } + + func test_WhenMovingFromCustomToDefaultAndBackToCustomThenPreviouslySelectedServerRetained() { + // GIVEN + model.isCustomDNSSelected = true + model.customDNSServers = "1.1.1.1" + model.applyDNSSettings() + model.isCustomDNSSelected = false + model.applyDNSSettings() + + // WHEN + model.isCustomDNSSelected = true + model.applyDNSSettings() + + // THEN + switch vpnSettings.dnsSettings { + case .custom(let servers): + XCTAssertEqual(servers, ["1.1.1.1"], "Custom DNS servers should be updated correctly.") + default: + XCTFail("Expected dnsSettings to be .custom, but got \(vpnSettings.dnsSettings)") + } + } + + func testWhenUpdateDNSSettingsToCustomAndNoServerProvidedPreviousDnsSettingApplies() { + // GIVEN + model.isCustomDNSSelected = false + model.applyDNSSettings() + let previousDNS = vpnSettings.dnsSettings + + // WHEN + model.customDNSServers = "" + model.isCustomDNSSelected = true + model.applyDNSSettings() + + // THEN + XCTAssertEqual(vpnSettings.dnsSettings, previousDNS, "DNS settings should remain unchanged when no custom DNS is provided.") + } + + func testToggleDNSSettings() { + // GIVEN + let initial = model.isCustomDNSSelected + + // WHEN + model.toggleDNSSettings() + + // THEN + XCTAssertEqual(model.isCustomDNSSelected, !initial, "toggleDNSSettings should invert the value.") + } + + func testToggleIsBlockRiskyDomainsOn() { + // GIVEN + let initial = model.isBlockRiskyDomainsOn + + // WHEN + model.toggleIsBlockRiskyDomainsOn() + + // THEN + XCTAssertEqual(model.isBlockRiskyDomainsOn, !initial, "toggleIsBlockRiskyDomainsOn should invert the value.") + } + + func testUpdateApplyButtonStateWhenValid() { + // GIVEN + model.isCustomDNSSelected = true + model.customDNSServers = "1.1.1.1" + + // WHEN + model.updateApplyButtonState() + + // THEN + XCTAssertTrue(model.isApplyButtonEnabled, "Apply button should be enabled for valid custom DNS.") + } + + func testUpdateApplyButtonStateWhenInvalid() { + // GIVEN + model.isCustomDNSSelected = true + model.customDNSServers = "invalid" + + // WHEN + model.updateApplyButtonState() + + // THEN + XCTAssertFalse(model.isApplyButtonEnabled, "Apply button should be disabled for invalid custom DNS.") + } + + func testUpdateApplyButtonStateWhenDefault() { + // GIVEN + model.isCustomDNSSelected = false + + // WHEN + model.updateApplyButtonState() + + // THEN + XCTAssertTrue(model.isApplyButtonEnabled, "Apply button should be enabled in default mode.") + } + +} + +private final class MockTunnelController: TunnelController { + func start() async { + } + + func stop() async { + } + + func command(_ command: NetworkProtection.VPNCommand) async throws { + } + + var isConnected: Bool = false +}