From 0222e3e7cfb6bd12f287ebad3863af98a8145173 Mon Sep 17 00:00:00 2001 From: Daniel Bernal Date: Mon, 19 Feb 2024 22:04:41 +0100 Subject: [PATCH] Subscription ITP Fixes (#2480) Task/Issue URL: https://app.asana.com/0/1204099484721401/1206585463538617/f Description: Adds allowedDomains setting to Webviews to prevent the user from loading pages outside of the scope Opens External Iris links in a separate Sheet Adds privacy protection and content blocking rules to Webviews Minor UI glitches and fixes Enabled links from Subscription Welcome page to ITR and PIR --- DuckDuckGo.xcodeproj/project.pbxproj | 10 +- .../xcshareddata/swiftpm/Package.resolved | 2 +- DuckDuckGo/SettingsSubscriptionView.swift | 18 ++++ DuckDuckGo/SettingsViewModel.swift | 1 + .../AsyncHeadlessWebView.swift | 11 ++- .../AsyncHeadlessWebViewModel.swift | 5 +- .../HeadlessWebView.swift | 29 +++++- .../HeadlessWebViewCoordinator.swift | 55 +++++++---- ...scriptionPagesUseSubscriptionFeature.swift | 2 +- .../SubscriptionEmailViewModel.swift | 10 +- .../SubscriptionExternalLinkViewModel.swift | 68 +++++++++++++ .../ViewModel/SubscriptionFlowViewModel.swift | 12 ++- .../ViewModel/SubscriptionITPViewModel.swift | 61 ++++++++++-- .../Views/SubscriptionExternalLinkView.swift | 96 +++++++++++++++++++ .../Views/SubscriptionITPView.swift | 17 +++- 15 files changed, 359 insertions(+), 38 deletions(-) create mode 100644 DuckDuckGo/Subscription/ViewModel/SubscriptionExternalLinkViewModel.swift create mode 100644 DuckDuckGo/Subscription/Views/SubscriptionExternalLinkView.swift diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index adf7e453d4..af2cdfa410 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -787,6 +787,8 @@ D668D9292B69681C008E2FF2 /* IdentityTheftRestorationPagesUserScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = D668D9282B69681C008E2FF2 /* IdentityTheftRestorationPagesUserScript.swift */; }; D668D92B2B696840008E2FF2 /* IdentityTheftRestorationPagesFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = D668D92A2B696840008E2FF2 /* IdentityTheftRestorationPagesFeature.swift */; }; D668D92D2B696945008E2FF2 /* Subscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = D668D92C2B696945008E2FF2 /* Subscription.swift */; }; + D68A21442B7EC08500BB372E /* SubscriptionExternalLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68A21432B7EC08500BB372E /* SubscriptionExternalLinkView.swift */; }; + D68A21462B7EC16200BB372E /* SubscriptionExternalLinkViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68A21452B7EC16200BB372E /* SubscriptionExternalLinkViewModel.swift */; }; D68DF81C2B58302E0023DBEA /* SubscriptionRestoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68DF81B2B58302E0023DBEA /* SubscriptionRestoreView.swift */; }; D68DF81E2B5830380023DBEA /* SubscriptionRestoreViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68DF81D2B5830380023DBEA /* SubscriptionRestoreViewModel.swift */; }; D69DBB502B72B1D300156310 /* View+TopMostController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D69DBB4F2B72B1D200156310 /* View+TopMostController.swift */; }; @@ -2436,6 +2438,8 @@ D668D9282B69681C008E2FF2 /* IdentityTheftRestorationPagesUserScript.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IdentityTheftRestorationPagesUserScript.swift; sourceTree = ""; }; D668D92A2B696840008E2FF2 /* IdentityTheftRestorationPagesFeature.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IdentityTheftRestorationPagesFeature.swift; sourceTree = ""; }; D668D92C2B696945008E2FF2 /* Subscription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Subscription.swift; sourceTree = ""; }; + D68A21432B7EC08500BB372E /* SubscriptionExternalLinkView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubscriptionExternalLinkView.swift; sourceTree = ""; }; + D68A21452B7EC16200BB372E /* SubscriptionExternalLinkViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubscriptionExternalLinkViewModel.swift; sourceTree = ""; }; D68DF81B2B58302E0023DBEA /* SubscriptionRestoreView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionRestoreView.swift; sourceTree = ""; }; D68DF81D2B5830380023DBEA /* SubscriptionRestoreViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionRestoreViewModel.swift; sourceTree = ""; }; D69DBB4F2B72B1D200156310 /* View+TopMostController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+TopMostController.swift"; sourceTree = ""; }; @@ -4512,6 +4516,7 @@ D64648AE2B5993890033090B /* SubscriptionEmailViewModel.swift */, D652498D2B515A6A0056B0DE /* SubscriptionSettingsViewModel.swift */, D6BFCB602B7525160051FF81 /* SubscriptionPIRViewModel.swift */, + D68A21452B7EC16200BB372E /* SubscriptionExternalLinkViewModel.swift */, ); path = ViewModel; sourceTree = ""; @@ -4533,6 +4538,7 @@ D68DF81B2B58302E0023DBEA /* SubscriptionRestoreView.swift */, D64648AC2B59936B0033090B /* SubscriptionEmailView.swift */, D668D9242B693778008E2FF2 /* SubscriptionITPView.swift */, + D68A21432B7EC08500BB372E /* SubscriptionExternalLinkView.swift */, D6BFCB5E2B7524AA0051FF81 /* SubscriptionPIRView.swift */, D6F93E3D2B50A8A0004C268D /* SubscriptionSettingsView.swift */, D6D95CE02B6D52DA00960317 /* RootPresentationMode.swift */, @@ -4545,9 +4551,9 @@ children = ( D668D92C2B696945008E2FF2 /* Subscription.swift */, D664C7B32B289AA000CBFA76 /* SubscriptionPagesUserScript.swift */, + D664C7B52B289AA000CBFA76 /* SubscriptionPagesUseSubscriptionFeature.swift */, D668D9282B69681C008E2FF2 /* IdentityTheftRestorationPagesUserScript.swift */, D668D92A2B696840008E2FF2 /* IdentityTheftRestorationPagesFeature.swift */, - D664C7B52B289AA000CBFA76 /* SubscriptionPagesUseSubscriptionFeature.swift */, ); path = UserScripts; sourceTree = ""; @@ -6618,6 +6624,7 @@ 31CB4251273AF50700FA0F3F /* SpeechRecognizerProtocol.swift in Sources */, 319A37172829C8AD0079FBCE /* UITableViewExtension.swift in Sources */, 85EE7F59224673C5000FE757 /* WebContainerNavigationController.swift in Sources */, + D68A21462B7EC16200BB372E /* SubscriptionExternalLinkViewModel.swift in Sources */, F4C9FBF528340DDA002281CC /* AutofillInterfaceEmailTruncator.swift in Sources */, 1E016AB42949FEB500F21625 /* OmniBarNotificationViewModel.swift in Sources */, 6AC6DAB328804F97002723C0 /* BarsAnimator.swift in Sources */, @@ -6645,6 +6652,7 @@ D6E83C602B22B3C9006C8AFB /* SettingsState.swift in Sources */, D6E83C482B20C812006C8AFB /* SettingsHostingController.swift in Sources */, F46FEC5727987A5F0061D9DF /* KeychainItemsDebugViewController.swift in Sources */, + D68A21442B7EC08500BB372E /* SubscriptionExternalLinkView.swift in Sources */, BD862E0B2B30F9300073E2EE /* VPNFeedbackFormView.swift in Sources */, 02341FA62A4379CC008A1531 /* OnboardingStepViewModel.swift in Sources */, 850365F323DE087800D0F787 /* UIImageViewExtension.swift in Sources */, diff --git a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index b6945916ac..18d1a22d06 100644 --- a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -12,7 +12,7 @@ { "identity" : "browserserviceskit", "kind" : "remoteSourceControl", - "location" : "https://github.com/DuckDuckGo/BrowserServicesKit", + "location" : "https://github.com/duckduckgo/BrowserServicesKit", "state" : { "revision" : "5ecf4fe56f334be6eaecb65f6d55632a6d53921c", "version" : "109.0.0" diff --git a/DuckDuckGo/SettingsSubscriptionView.swift b/DuckDuckGo/SettingsSubscriptionView.swift index 02b5acda25..3d4f3253ad 100644 --- a/DuckDuckGo/SettingsSubscriptionView.swift +++ b/DuckDuckGo/SettingsSubscriptionView.swift @@ -113,6 +113,24 @@ struct SettingsSubscriptionView: View { } }) + .onChange(of: viewModel.shouldNavigateToDBP, perform: { value in + if value { + // Allow the sheet to dismiss before presenting a new one + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.3) { + isShowingDBP = true + } + } + }) + + .onChange(of: viewModel.shouldNavigateToITP, perform: { value in + if value { + // Allow the sheet to dismiss before presenting a new one + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.3) { + isShowingITP = true + } + } + }) + .onReceive(subscriptionFlowViewModel.$selectedFeature) { value in guard let value else { return } viewModel.onAppearNavigationTarget = value diff --git a/DuckDuckGo/SettingsViewModel.swift b/DuckDuckGo/SettingsViewModel.swift index 2d937f2d9d..1b84d3a200 100644 --- a/DuckDuckGo/SettingsViewModel.swift +++ b/DuckDuckGo/SettingsViewModel.swift @@ -97,6 +97,7 @@ final class SettingsViewModel: ObservableObject { enum SettingsSection: String { case none, netP, dbp, itr } + @Published var onAppearNavigationTarget: SettingsSection // MARK: Bindings diff --git a/DuckDuckGo/Subscription/AsyncHeadlessWebview/AsyncHeadlessWebView.swift b/DuckDuckGo/Subscription/AsyncHeadlessWebview/AsyncHeadlessWebView.swift index 74b5224f9d..aa6a8fdbce 100644 --- a/DuckDuckGo/Subscription/AsyncHeadlessWebview/AsyncHeadlessWebView.swift +++ b/DuckDuckGo/Subscription/AsyncHeadlessWebview/AsyncHeadlessWebView.swift @@ -26,9 +26,18 @@ import Core struct AsyncHeadlessWebViewSettings { let bounces: Bool + let javascriptEnabled: Bool + let allowedDomains: [String]? + let contentBlocking: Bool - init(bounces: Bool = false) { + init(bounces: Bool = true, + javascriptEnabled: Bool = true, + allowedDomains: [String]? = nil, + contentBlocking: Bool = true) { self.bounces = bounces + self.javascriptEnabled = javascriptEnabled + self.allowedDomains = allowedDomains + self.contentBlocking = contentBlocking } } diff --git a/DuckDuckGo/Subscription/AsyncHeadlessWebview/AsyncHeadlessWebViewModel.swift b/DuckDuckGo/Subscription/AsyncHeadlessWebview/AsyncHeadlessWebViewModel.swift index 304908891c..ef5b1878fe 100644 --- a/DuckDuckGo/Subscription/AsyncHeadlessWebview/AsyncHeadlessWebViewModel.swift +++ b/DuckDuckGo/Subscription/AsyncHeadlessWebview/AsyncHeadlessWebViewModel.swift @@ -37,10 +37,13 @@ final class AsyncHeadlessWebViewViewModel: ObservableObject { @Published var canGoBack: Bool = false @Published var canGoForward: Bool = false @Published var contentType: String = "" + @Published var allowedDomains: [String]? var navigationCoordinator = HeadlessWebViewNavCoordinator(webView: nil) - init(userScript: UserScriptMessaging?, subFeature: Subfeature?, settings: AsyncHeadlessWebViewSettings) { + init(userScript: UserScriptMessaging? = nil, + subFeature: Subfeature? = nil, + settings: AsyncHeadlessWebViewSettings) { self.userScript = userScript self.subFeature = subFeature self.settings = settings diff --git a/DuckDuckGo/Subscription/AsyncHeadlessWebview/HeadlessWebView.swift b/DuckDuckGo/Subscription/AsyncHeadlessWebview/HeadlessWebView.swift index 5aa39ae262..a0e2acd8c6 100644 --- a/DuckDuckGo/Subscription/AsyncHeadlessWebview/HeadlessWebView.swift +++ b/DuckDuckGo/Subscription/AsyncHeadlessWebview/HeadlessWebView.swift @@ -21,6 +21,7 @@ import Foundation import SwiftUI import WebKit import UserScript +import BrowserServicesKit struct HeadlessWebView: UIViewRepresentable { let userScript: UserScriptMessaging? @@ -33,18 +34,24 @@ struct HeadlessWebView: UIViewRepresentable { var onContentType: ((String) -> Void)? var navigationCoordinator: HeadlessWebViewNavCoordinator - func makeUIView(context: Context) -> WKWebView { let configuration = WKWebViewConfiguration() configuration.userContentController = makeUserContentController() - let webView = WKWebView(frame: .zero, configuration: configuration) + let preferences = WKWebpagePreferences() + preferences.allowsContentJavaScript = settings.javascriptEnabled + preferences.preferredContentMode = .mobile + configuration.defaultWebpagePreferences = preferences - navigationCoordinator.webView = webView + let webView = WKWebView(frame: .zero, configuration: configuration) webView.uiDelegate = context.coordinator webView.scrollView.delegate = context.coordinator webView.scrollView.bounces = settings.bounces + webView.scrollView.contentInsetAdjustmentBehavior = .never webView.navigationDelegate = context.coordinator + webView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + navigationCoordinator.webView = webView + #if DEBUG if #available(iOS 16.4, *) { @@ -64,16 +71,30 @@ struct HeadlessWebView: UIViewRepresentable { onURLChange: onURLChange, onCanGoBack: onCanGoBack, onCanGoForward: onCanGoForward, - onContentType: onContentType) + onContentType: onContentType, + settings: settings + ) } @MainActor private func makeUserContentController() -> WKUserContentController { let userContentController = WKUserContentController() + + // Enable content blocking rules + if settings.contentBlocking { + let sourceProvider = DefaultScriptSourceProvider() + let contentBlockerUserScript = ContentBlockerRulesUserScript(configuration: sourceProvider.contentBlockerRulesConfig) + let contentScopeUserScript = ContentScopeUserScript(sourceProvider.privacyConfigurationManager, + properties: sourceProvider.contentScopeProperties) + userContentController.addUserScript(contentBlockerUserScript.makeWKUserScriptSync()) + userContentController.addUserScript(contentScopeUserScript.makeWKUserScriptSync()) + } + if let userScript, let subFeature { userContentController.addUserScript(userScript.makeWKUserScriptSync()) userContentController.addHandler(userScript) userScript.registerSubfeature(delegate: subFeature) + } return userContentController } diff --git a/DuckDuckGo/Subscription/AsyncHeadlessWebview/HeadlessWebViewCoordinator.swift b/DuckDuckGo/Subscription/AsyncHeadlessWebview/HeadlessWebViewCoordinator.swift index a2fb375dc7..dd14a819ee 100644 --- a/DuckDuckGo/Subscription/AsyncHeadlessWebview/HeadlessWebViewCoordinator.swift +++ b/DuckDuckGo/Subscription/AsyncHeadlessWebview/HeadlessWebViewCoordinator.swift @@ -27,6 +27,9 @@ final class HeadlessWebViewCoordinator: NSObject { var onCanGoBack: ((Bool) -> Void)? var onCanGoForward: ((Bool) -> Void)? var onContentType: ((String) -> Void)? + var settings: AsyncHeadlessWebViewSettings + + var size: CGSize = .zero private var lastURL: URL? @@ -44,13 +47,16 @@ final class HeadlessWebViewCoordinator: NSObject { onURLChange: ((URL) -> Void)?, onCanGoBack: ((Bool) -> Void)?, onCanGoForward: ((Bool) -> Void)?, - onContentType: ((String) -> Void)?) { + onContentType: ((String) -> Void)?, + allowedDomains: [String]? = nil, + settings: AsyncHeadlessWebViewSettings = AsyncHeadlessWebViewSettings()) { self.parent = parent self.onScroll = onScroll self.onURLChange = onURLChange self.onCanGoBack = onCanGoBack self.onCanGoForward = onCanGoForward self.onContentType = onContentType + self.settings = settings } func setupWebViewObservation(_ webView: WKWebView) { @@ -107,32 +113,49 @@ extension HeadlessWebViewCoordinator: WKNavigationDelegate { } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - webView.evaluateJavaScript(Constants.contentTypeJS) { result, error in - guard error == nil, let contentType = result as? String else { - return + if settings.javascriptEnabled { + webView.evaluateJavaScript(Constants.contentTypeJS) { result, error in + guard error == nil, let contentType = result as? String else { + return + } + self.onContentType?(contentType) } - self.onContentType?(contentType) } } func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { - guard let url = navigationAction.request.url else { - - decisionHandler(.allow) - return + guard let url = navigationAction.request.url, let scheme = url.scheme else { + decisionHandler(.cancel) + return } - - guard let scheme = url.scheme else { + + // Handle custom schemes (e.g., tel:, facetime:, etc.) + if Constants.externalSchemes.contains(scheme), UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url, options: [:], completionHandler: nil) decisionHandler(.cancel) return } + + // Publish the URL change + self.onURLChange?(url) + lastURL = url - if Constants.externalSchemes.contains(scheme) && UIApplication.shared.canOpenURL(url) { - UIApplication.shared.open(url, options: [:], completionHandler: nil) - decisionHandler(.cancel) - } else { - decisionHandler(.allow) + // Validate the URL against allowed domains list, if present + if let allowedDomains = settings.allowedDomains, !allowedDomains.isEmpty { + let isURLAllowed = allowedDomains.contains { domain in + url.isPart(ofDomain: domain) + } + + decisionHandler(isURLAllowed ? .allow : .cancel) + return } + + // Default policy: allow navigation + decisionHandler(.allow) + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + // NOOP } } diff --git a/DuckDuckGo/Subscription/UserScripts/SubscriptionPagesUseSubscriptionFeature.swift b/DuckDuckGo/Subscription/UserScripts/SubscriptionPagesUseSubscriptionFeature.swift index 90b2eacc49..dbfef2b806 100644 --- a/DuckDuckGo/Subscription/UserScripts/SubscriptionPagesUseSubscriptionFeature.swift +++ b/DuckDuckGo/Subscription/UserScripts/SubscriptionPagesUseSubscriptionFeature.swift @@ -266,7 +266,7 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature, ObservableObjec await withTransactionInProgress { transactionStatus = .restoring switch await AppStoreRestoreFlow.restoreAccountFromPastPurchase() { - case .success(let update): + case .success: return true case .failure: return false diff --git a/DuckDuckGo/Subscription/ViewModel/SubscriptionEmailViewModel.swift b/DuckDuckGo/Subscription/ViewModel/SubscriptionEmailViewModel.swift index 0a492e5e25..2bcb373c9e 100644 --- a/DuckDuckGo/Subscription/ViewModel/SubscriptionEmailViewModel.swift +++ b/DuckDuckGo/Subscription/ViewModel/SubscriptionEmailViewModel.swift @@ -39,6 +39,12 @@ final class SubscriptionEmailViewModel: ObservableObject { @Published var managingSubscriptionEmail = false @Published var webViewModel: AsyncHeadlessWebViewViewModel + private static let allowedDomains = [ + "duckduckgo.com", + "microsoftonline.com", + "duosecurity.com", + ] + private var cancellables = Set() init(userScript: SubscriptionPagesUserScript = SubscriptionPagesUserScript(), @@ -49,7 +55,9 @@ final class SubscriptionEmailViewModel: ObservableObject { self.accountManager = accountManager self.webViewModel = AsyncHeadlessWebViewViewModel(userScript: userScript, subFeature: subFeature, - settings: AsyncHeadlessWebViewSettings(bounces: false)) + settings: AsyncHeadlessWebViewSettings(bounces: false, + allowedDomains: Self.allowedDomains, + contentBlocking: false)) initializeView() setupTransactionObservers() } diff --git a/DuckDuckGo/Subscription/ViewModel/SubscriptionExternalLinkViewModel.swift b/DuckDuckGo/Subscription/ViewModel/SubscriptionExternalLinkViewModel.swift new file mode 100644 index 0000000000..391b430874 --- /dev/null +++ b/DuckDuckGo/Subscription/ViewModel/SubscriptionExternalLinkViewModel.swift @@ -0,0 +1,68 @@ +// +// SubscriptionExternalLinkViewModel.swift +// DuckDuckGo +// +// Copyright © 2024 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 Core +import Combine + +#if SUBSCRIPTION +@available(iOS 15.0, *) +final class SubscriptionExternalLinkViewModel: ObservableObject { + + var url: URL + var allowedDomains: [String]? + private var canGoBackCancellable: AnyCancellable? + + @Published var webViewModel: AsyncHeadlessWebViewViewModel + @Published var canNavigateBack: Bool = false + + private var cancellables = Set() + + init(url: URL, allowedDomains: [String]? = nil) { + let webViewSettings = AsyncHeadlessWebViewSettings(bounces: false, + allowedDomains: allowedDomains, + contentBlocking: true) + + self.url = url + self.webViewModel = AsyncHeadlessWebViewViewModel(settings: webViewSettings) + } + + // Observe transaction status + private func setupSubscribers() async { + + canGoBackCancellable = webViewModel.$canGoBack + .receive(on: DispatchQueue.main) + .sink { [weak self] value in + self?.canNavigateBack = value + } + } + + func initializeView() { + Task { await setupSubscribers() } + webViewModel.navigationCoordinator.navigateTo(url: url) + + } + + @MainActor + func navigateBack() async { + await webViewModel.navigationCoordinator.goBack() + } + +} +#endif diff --git a/DuckDuckGo/Subscription/ViewModel/SubscriptionFlowViewModel.swift b/DuckDuckGo/Subscription/ViewModel/SubscriptionFlowViewModel.swift index d42dbb0e61..f8b1cc0220 100644 --- a/DuckDuckGo/Subscription/ViewModel/SubscriptionFlowViewModel.swift +++ b/DuckDuckGo/Subscription/ViewModel/SubscriptionFlowViewModel.swift @@ -58,6 +58,16 @@ final class SubscriptionFlowViewModel: ObservableObject { @Published var shouldShowNavigationBar: Bool = false @Published var selectedFeature: SettingsViewModel.SettingsSection? @Published var canNavigateBack: Bool = false + + private static let allowedDomains = [ + "duckduckgo.com", + "microsoftonline.com", + "duosecurity.com", + ] + + private var webViewSettings = AsyncHeadlessWebViewSettings(bounces: false, + allowedDomains: allowedDomains, + contentBlocking: false) init(userScript: SubscriptionPagesUserScript = SubscriptionPagesUserScript(), subFeature: SubscriptionPagesUseSubscriptionFeature = SubscriptionPagesUseSubscriptionFeature(), @@ -69,7 +79,7 @@ final class SubscriptionFlowViewModel: ObservableObject { self.selectedFeature = selectedFeature self.webViewModel = AsyncHeadlessWebViewViewModel(userScript: userScript, subFeature: subFeature, - settings: AsyncHeadlessWebViewSettings(bounces: false)) + settings: webViewSettings) } // Observe transaction status diff --git a/DuckDuckGo/Subscription/ViewModel/SubscriptionITPViewModel.swift b/DuckDuckGo/Subscription/ViewModel/SubscriptionITPViewModel.swift index d189f1a6f1..0ff8125eb8 100644 --- a/DuckDuckGo/Subscription/ViewModel/SubscriptionITPViewModel.swift +++ b/DuckDuckGo/Subscription/ViewModel/SubscriptionITPViewModel.swift @@ -30,11 +30,13 @@ final class SubscriptionITPViewModel: ObservableObject { let userScript: IdentityTheftRestorationPagesUserScript let subFeature: IdentityTheftRestorationPagesFeature var manageITPURL = URL.identityTheftRestoration - var viewTitle = UserText.settingsPProITRTitle + var viewTitle = UserText.subscriptionTitle enum Constants { - static let navigationBarHideThreshold = 40.0 + static let navigationBarHideThreshold = 60.0 static let downloadableContent = ["application/pdf"] + static let blankURL = "about:blank" + static let externalSchemes = ["tel", "sms", "facetime"] } // State variables @@ -45,8 +47,23 @@ final class SubscriptionITPViewModel: ObservableObject { @Published var isDownloadableContent: Bool = false @Published var activityItems: [Any] = [] @Published var attachmentURL: URL? + + @Published var shouldNavigateToExternalURL: URL? + var shouldShowExternalURLSheet: Bool { + shouldNavigateToExternalURL != nil + } + private var currentURL: URL? + private static let allowedDomains = [ + "duckduckgo.com", + "microsoftonline.com", + "duosecurity.com", + ] + private var externalLinksViewModel: SubscriptionExternalLinkViewModel? + // Limit navigation to these external domains + private var externalAllowedDomains = ["irisidentityprotection.com"] + private var cancellables = Set() private var canGoBackCancellable: AnyCancellable? @@ -54,9 +71,14 @@ final class SubscriptionITPViewModel: ObservableObject { subFeature: IdentityTheftRestorationPagesFeature = IdentityTheftRestorationPagesFeature()) { self.userScript = userScript self.subFeature = subFeature + + let webViewSettings = AsyncHeadlessWebViewSettings(bounces: false, + allowedDomains: Self.allowedDomains, + contentBlocking: false) + self.webViewModel = AsyncHeadlessWebViewViewModel(userScript: userScript, subFeature: subFeature, - settings: AsyncHeadlessWebViewSettings(bounces: false)) + settings: webViewSettings) } // Observe transaction status @@ -64,8 +86,9 @@ final class SubscriptionITPViewModel: ObservableObject { webViewModel.$scrollPosition .receive(on: DispatchQueue.main) + .throttle(for: .milliseconds(100), scheduler: DispatchQueue.main, latest: true) .sink { [weak self] value in - self?.shouldShowNavigationBar = value.y > Constants.navigationBarHideThreshold + self?.shouldShowNavigationBar = (value.y > Constants.navigationBarHideThreshold) } .store(in: &cancellables) @@ -91,13 +114,25 @@ final class SubscriptionITPViewModel: ObservableObject { webViewModel.$url .receive(on: DispatchQueue.main) - .sink { [weak self] value in - self?.isDownloadableContent = false - self?.currentURL = value + .sink { [weak self] url in + guard let self = self, let url = url else { return } + + // Check if allowedDomains is empty or if the URL is valid or part of the allowed domains + if Self.allowedDomains.isEmpty || + Self.allowedDomains.contains(where: { url.isPart(ofDomain: $0) }), + self.shouldNavigateToExternalURL == nil { + self.isDownloadableContent = false + self.currentURL = url + } else { + // Fire up navigation in a separate View (if a valid link) + if url.absoluteString != Constants.blankURL && + !Constants.externalSchemes.contains(url.scheme ?? "") { + self.shouldNavigateToExternalURL = url + } + } } .store(in: &cancellables) - canGoBackCancellable = webViewModel.$canGoBack .receive(on: DispatchQueue.main) .sink { [weak self] value in @@ -128,6 +163,16 @@ final class SubscriptionITPViewModel: ObservableObject { } } } + + func getExternalLinksViewModel(url: URL) -> SubscriptionExternalLinkViewModel { + if let existingModel = externalLinksViewModel { + return existingModel + } else { + let model = SubscriptionExternalLinkViewModel(url: url, allowedDomains: externalAllowedDomains) + externalLinksViewModel = model + return model + } + } @MainActor diff --git a/DuckDuckGo/Subscription/Views/SubscriptionExternalLinkView.swift b/DuckDuckGo/Subscription/Views/SubscriptionExternalLinkView.swift new file mode 100644 index 0000000000..badb1932bd --- /dev/null +++ b/DuckDuckGo/Subscription/Views/SubscriptionExternalLinkView.swift @@ -0,0 +1,96 @@ +// +// SubscriptionExternalLinkView.swift +// DuckDuckGo +// +// Copyright © 2023 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. +// + +#if SUBSCRIPTION +import SwiftUI +import Foundation +import DesignResourcesKit + +@available(iOS 15.0, *) +struct SubscriptionExternalLinkView: View { + + @Environment(\.dismiss) var dismiss + @ObservedObject var viewModel: SubscriptionExternalLinkViewModel + + enum Constants { + static let navButtonPadding: CGFloat = 20.0 + static let backButtonImage = "chevron.left" + } + + + var body: some View { + NavigationView { + baseView + .toolbar { + ToolbarItemGroup(placement: .navigationBarLeading) { + backButton + } + ToolbarItem(placement: .navigationBarTrailing) { + Button(UserText.subscriptionCloseButton) { dismiss() } + } + } + .navigationBarTitleDisplayMode(.inline) + .navigationViewStyle(.stack) + + .onAppear(perform: { + setUpAppearances() + viewModel.initializeView() + }) + }.tint(Color(designSystemColor: .textPrimary)) + } + + private var baseView: some View { + ZStack(alignment: .top) { + webView + } + } + + @ViewBuilder + private var webView: some View { + + ZStack(alignment: .top) { + AsyncHeadlessWebView(viewModel: viewModel.webViewModel) + .background() + } + } + + @ViewBuilder + private var backButton: some View { + if viewModel.canNavigateBack { + Button(action: { + Task { await viewModel.navigateBack() } + }, label: { + HStack(spacing: 0) { + Image(systemName: Constants.backButtonImage) + Text(UserText.backButtonTitle) + } + }) + } + } + + + private func setUpAppearances() { + let navAppearance = UINavigationBar.appearance() + navAppearance.backgroundColor = UIColor(designSystemColor: .surface) + navAppearance.barTintColor = UIColor(designSystemColor: .surface) + navAppearance.shadowImage = UIImage() + navAppearance.tintColor = UIColor(designSystemColor: .textPrimary) + } +} +#endif diff --git a/DuckDuckGo/Subscription/Views/SubscriptionITPView.swift b/DuckDuckGo/Subscription/Views/SubscriptionITPView.swift index 7d8c83c7b6..6111e75246 100644 --- a/DuckDuckGo/Subscription/Views/SubscriptionITPView.swift +++ b/DuckDuckGo/Subscription/Views/SubscriptionITPView.swift @@ -73,15 +73,26 @@ struct SubscriptionITPView: View { Button(UserText.subscriptionCloseButton) { dismiss() } } } - .edgesIgnoringSafeArea(.top) + .edgesIgnoringSafeArea(.all) .navigationBarTitleDisplayMode(.inline) - .navigationBarHidden(!viewModel.shouldShowNavigationBar && !viewModel.isDownloadableContent).animation(.easeOut) + .navigationBarHidden(!viewModel.shouldShowNavigationBar && !viewModel.isDownloadableContent).animation(.snappy) .onAppear(perform: { setUpAppearances() viewModel.initializeView() }) - }.tint(Color(designSystemColor: .textPrimary)) + + } + .tint(Color(designSystemColor: .textPrimary)) + + .sheet(isPresented: Binding( + get: { viewModel.shouldShowExternalURLSheet }, + set: { if !$0 { viewModel.shouldNavigateToExternalURL = nil } } + )) { + if let url = viewModel.shouldNavigateToExternalURL { + SubscriptionExternalLinkView(viewModel: viewModel.getExternalLinksViewModel(url: url)) + } + } } private var baseView: some View {