From 32510976c1f2366abf0706e26fb517f71f7c1436 Mon Sep 17 00:00:00 2001 From: Daniel Bernal Date: Thu, 4 Apr 2024 17:06:42 +0200 Subject: [PATCH] Subscriptions. 28. Intercept Privacy Pro URL + Navigation Update Ship review fixes (#2670) Task/Issue URL: https://app.asana.com/0/0/1206983541370659/f Implements a URLInterceptor class to define custom actions when visiting specific URLs Other UI fixes based on Ship Review for navigation Updates Minor updates to cancellable removals and deInit cleanUp --- DuckDuckGo.xcodeproj/project.pbxproj | 12 +++ DuckDuckGo/MainViewController+Segues.swift | 7 +- DuckDuckGo/MainViewController.swift | 18 +++- .../Extensions/View+AppearModifiers.swift | 23 ----- .../SubscriptionEmailViewModel.swift | 78 ++++++++++----- .../SubscriptionExternalLinkViewModel.swift | 9 +- .../ViewModel/SubscriptionFlowViewModel.swift | 52 ++++++++-- .../ViewModel/SubscriptionITPViewModel.swift | 8 +- .../SubscriptionRestoreViewModel.swift | 20 ++-- .../Views/DaxLogoNavbarTitle.swift | 39 ++++++++ .../Views/SubscriptionEmailView.swift | 16 ++- .../Views/SubscriptionFlowView.swift | 20 ++-- .../Views/SubscriptionITPView.swift | 10 +- .../Views/SubscriptionPIRView.swift | 10 +- .../Views/SubscriptionRestoreView.swift | 7 +- .../Views/SubscriptionSettingsView.swift | 4 +- DuckDuckGo/TabURLInterceptor.swift | 99 +++++++++++++++++++ DuckDuckGo/TabViewController.swift | 25 ++++- DuckDuckGoTests/TabURLInterceptorTests.swift | 65 ++++++++++++ 19 files changed, 408 insertions(+), 114 deletions(-) create mode 100644 DuckDuckGo/Subscription/Views/DaxLogoNavbarTitle.swift create mode 100644 DuckDuckGo/TabURLInterceptor.swift create mode 100644 DuckDuckGoTests/TabURLInterceptorTests.swift diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 5387560ef0..f537ec6d44 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -802,7 +802,9 @@ D60B1F272B9DDE5A00AE4760 /* SubscriptionGoogleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60B1F262B9DDE5A00AE4760 /* SubscriptionGoogleView.swift */; }; D61CDA162B7CF77300A0FBB9 /* Subscription in Frameworks */ = {isa = PBXBuildFile; productRef = D61CDA152B7CF77300A0FBB9 /* Subscription */; }; D61CDA182B7CF78300A0FBB9 /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = D61CDA172B7CF78300A0FBB9 /* ZIPFoundation */; }; + D625AAEC2BBEF27600BC189A /* TabURLInterceptorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D625AAEA2BBEEFC900BC189A /* TabURLInterceptorTests.swift */; }; D63657192A7BAE7C001AF19D /* EmailManagerRequestDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63657182A7BAE7C001AF19D /* EmailManagerRequestDelegate.swift */; }; + D63677F52BBDB1C300605BA5 /* DaxLogoNavbarTitle.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63677F42BBDB1C300605BA5 /* DaxLogoNavbarTitle.swift */; }; D64648AD2B59936B0033090B /* SubscriptionEmailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64648AC2B59936B0033090B /* SubscriptionEmailView.swift */; }; D64648AF2B5993890033090B /* SubscriptionEmailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64648AE2B5993890033090B /* SubscriptionEmailViewModel.swift */; }; D652498E2B515A6A0056B0DE /* SubscriptionSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D652498D2B515A6A0056B0DE /* SubscriptionSettingsViewModel.swift */; }; @@ -828,6 +830,7 @@ D68DF81C2B58302E0023DBEA /* SubscriptionRestoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68DF81B2B58302E0023DBEA /* SubscriptionRestoreView.swift */; }; D68DF81E2B5830380023DBEA /* SubscriptionRestoreViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68DF81D2B5830380023DBEA /* SubscriptionRestoreViewModel.swift */; }; D69FBF762B28BE3600B505F1 /* SettingsSubscriptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D69FBF752B28BE3600B505F1 /* SettingsSubscriptionView.swift */; }; + D6ACEA322BBD55BF008FADDF /* TabURLInterceptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6ACEA312BBD55BF008FADDF /* TabURLInterceptor.swift */; }; D6BFCB5F2B7524AA0051FF81 /* SubscriptionPIRView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BFCB5E2B7524AA0051FF81 /* SubscriptionPIRView.swift */; }; D6BFCB612B7525160051FF81 /* SubscriptionPIRViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BFCB602B7525160051FF81 /* SubscriptionPIRViewModel.swift */; }; D6D95CE32B6D9F8800960317 /* AsyncHeadlessWebViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D95CE22B6D9F8800960317 /* AsyncHeadlessWebViewModel.swift */; }; @@ -2476,7 +2479,9 @@ CBFCB30D2B2CD47800253E9E /* ConfigurationURLDebugViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationURLDebugViewController.swift; sourceTree = ""; }; D60170BB2BA32DD6001911B5 /* Subscription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Subscription.swift; sourceTree = ""; }; D60B1F262B9DDE5A00AE4760 /* SubscriptionGoogleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionGoogleView.swift; sourceTree = ""; }; + D625AAEA2BBEEFC900BC189A /* TabURLInterceptorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabURLInterceptorTests.swift; sourceTree = ""; }; D63657182A7BAE7C001AF19D /* EmailManagerRequestDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EmailManagerRequestDelegate.swift; sourceTree = ""; }; + D63677F42BBDB1C300605BA5 /* DaxLogoNavbarTitle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DaxLogoNavbarTitle.swift; sourceTree = ""; }; D64648AC2B59936B0033090B /* SubscriptionEmailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionEmailView.swift; sourceTree = ""; }; D64648AE2B5993890033090B /* SubscriptionEmailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionEmailViewModel.swift; sourceTree = ""; }; D652498D2B515A6A0056B0DE /* SubscriptionSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionSettingsViewModel.swift; sourceTree = ""; }; @@ -2502,6 +2507,7 @@ D68DF81B2B58302E0023DBEA /* SubscriptionRestoreView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionRestoreView.swift; sourceTree = ""; }; D68DF81D2B5830380023DBEA /* SubscriptionRestoreViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionRestoreViewModel.swift; sourceTree = ""; }; D69FBF752B28BE3600B505F1 /* SettingsSubscriptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSubscriptionView.swift; sourceTree = ""; }; + D6ACEA312BBD55BF008FADDF /* TabURLInterceptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabURLInterceptor.swift; sourceTree = ""; }; D6BFCB5E2B7524AA0051FF81 /* SubscriptionPIRView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionPIRView.swift; sourceTree = ""; }; D6BFCB602B7525160051FF81 /* SubscriptionPIRViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionPIRViewModel.swift; sourceTree = ""; }; D6D95CE22B6D9F8800960317 /* AsyncHeadlessWebViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncHeadlessWebViewModel.swift; sourceTree = ""; }; @@ -4677,6 +4683,7 @@ D6F93E3D2B50A8A0004C268D /* SubscriptionSettingsView.swift */, D60B1F262B9DDE5A00AE4760 /* SubscriptionGoogleView.swift */, D670E5BA2BB6A75200941A42 /* SubscriptionNavigationCoordinator.swift */, + D63677F42BBDB1C300605BA5 /* DaxLogoNavbarTitle.swift */, ); path = Views; sourceTree = ""; @@ -5121,6 +5128,7 @@ B60DFF062872B64B0061E7C2 /* JSAlertController.swift */, B6BA95E728924730004ABA20 /* JSAlertController.storyboard */, 85010501292FB1000033978F /* FireproofFaviconUpdater.swift */, + D6ACEA312BBD55BF008FADDF /* TabURLInterceptor.swift */, ); name = UI; sourceTree = ""; @@ -5134,6 +5142,7 @@ F13B4BFA1F18E3D900814661 /* TabsModelPersistenceExtensionTests.swift */, F13B4BF81F18CA0600814661 /* TabsModelTests.swift */, F189AED61F18F6DE001EBAE1 /* TabTests.swift */, + D625AAEA2BBEEFC900BC189A /* TabURLInterceptorTests.swift */, ); name = Tabs; sourceTree = ""; @@ -6653,6 +6662,7 @@ C1D21E2D293A5965006E5A05 /* AutofillLoginSession.swift in Sources */, 4B53648A26718D0E001AA041 /* EmailWaitlist.swift in Sources */, 027F48762A4B5FBE001A1C6C /* AppTPLinkButton.swift in Sources */, + D63677F52BBDB1C300605BA5 /* DaxLogoNavbarTitle.swift in Sources */, 8524CC98246D66E100E59D45 /* String+Markdown.swift in Sources */, CBEFB9142AE0844700DEDE7B /* CriticalAlerts.swift in Sources */, 020108A329A561C300644F9D /* AppTPActivityView.swift in Sources */, @@ -6918,6 +6928,7 @@ D6E83C562B21ECC1006C8AFB /* SettingsLegacyViewProvider.swift in Sources */, 98B31292218CCB8C00E54DE1 /* AppDependencyProvider.swift in Sources */, D670E5BD2BB6AA0000941A42 /* View+AppearModifiers.swift in Sources */, + D6ACEA322BBD55BF008FADDF /* TabURLInterceptor.swift in Sources */, C13F3F6A2B7F883A0083BE40 /* AuthConfirmationPromptViewController.swift in Sources */, 02C57C4B2514FEFB009E5129 /* DoNotSellSettingsViewController.swift in Sources */, 02A54A9C2A097C95000C8FED /* AppTPHomeViewSectionRenderer.swift in Sources */, @@ -7060,6 +7071,7 @@ 85C11E4120904BBE00BFFEB4 /* VariantManagerTests.swift in Sources */, F1134ECE1F40EA9C00B73467 /* AtbParserTests.swift in Sources */, F189AEE41F18FDAF001EBAE1 /* LinkTests.swift in Sources */, + D625AAEC2BBEF27600BC189A /* TabURLInterceptorTests.swift in Sources */, 987130C7294AAB9F00AB05E0 /* MenuBookmarksViewModelTests.swift in Sources */, 858650D32469BFAD00C36F8A /* DaxDialogTests.swift in Sources */, 31C138B227A4097800FFD4B2 /* DownloadTestsHelper.swift in Sources */, diff --git a/DuckDuckGo/MainViewController+Segues.swift b/DuckDuckGo/MainViewController+Segues.swift index 8b6299af34..7a36e77d1a 100644 --- a/DuckDuckGo/MainViewController+Segues.swift +++ b/DuckDuckGo/MainViewController+Segues.swift @@ -244,14 +244,17 @@ extension MainViewController { } } - private func launchSettings(completion: ((SettingsViewModel) -> Void)? = nil) { + func launchSettings(completion: ((SettingsViewModel) -> Void)? = nil, + deepLinkTarget: SettingsViewModel.SettingsDeepLinkSection? = nil) { let legacyViewProvider = SettingsLegacyViewProvider(syncService: syncService, syncDataProviders: syncDataProviders, appSettings: appSettings, bookmarksDatabase: bookmarksDatabase, tabManager: tabManager) #if SUBSCRIPTION - let settingsViewModel = SettingsViewModel(legacyViewProvider: legacyViewProvider, accountManager: AccountManager()) + let settingsViewModel = SettingsViewModel(legacyViewProvider: legacyViewProvider, + accountManager: AccountManager(), + deepLink: deepLinkTarget) #else let settingsViewModel = SettingsViewModel(legacyViewProvider: legacyViewProvider) #endif diff --git a/DuckDuckGo/MainViewController.swift b/DuckDuckGo/MainViewController.swift index 3157597a2a..e144e49c99 100644 --- a/DuckDuckGo/MainViewController.swift +++ b/DuckDuckGo/MainViewController.swift @@ -107,6 +107,7 @@ class MainViewController: UIViewController { private var syncFeatureFlagsCancellable: AnyCancellable? private var favoritesDisplayModeCancellable: AnyCancellable? private var emailCancellables = Set() + private var urlInterceptorCancellables = Set() #if NETWORK_PROTECTION private let tunnelDefaults = UserDefaults.networkProtectionGroupDefaults @@ -268,7 +269,8 @@ class MainViewController: UIViewController { previewsSource.prepare() addLaunchTabNotificationObserver() subscribeToEmailProtectionStatusNotifications() - + subscribeToURLInterceptorNotifications() + #if NETWORK_PROTECTION && SUBSCRIPTION subscribeToNetworkProtectionEvents() #endif @@ -1351,6 +1353,20 @@ class MainViewController: UIViewController { } .store(in: &emailCancellables) } + + private func subscribeToURLInterceptorNotifications() { + NotificationCenter.default.publisher(for: .urlInterceptPrivacyPro) + .receive(on: DispatchQueue.main) + .sink { [weak self] notification in + switch notification.name { + case .urlInterceptPrivacyPro: + self?.launchSettings(deepLinkTarget: .subscriptionFlow) + default: + return + } + } + .store(in: &urlInterceptorCancellables) + } #if NETWORK_PROTECTION && SUBSCRIPTION private func subscribeToNetworkProtectionEvents() { diff --git a/DuckDuckGo/Subscription/Extensions/View+AppearModifiers.swift b/DuckDuckGo/Subscription/Extensions/View+AppearModifiers.swift index df8ce7bda2..bd7c78138d 100644 --- a/DuckDuckGo/Subscription/Extensions/View+AppearModifiers.swift +++ b/DuckDuckGo/Subscription/Extensions/View+AppearModifiers.swift @@ -38,33 +38,10 @@ public struct OnFirstAppearModifier: ViewModifier { } } -public struct OnFirstDisappearModifier: ViewModifier { - - private let onFirstDisappearAction: () -> Void - @State private var hasDisappeared = false - - public init(_ onFirstDisappearAction: @escaping () -> Void) { - self.onFirstDisappearAction = onFirstDisappearAction - } - - public func body(content: Content) -> some View { - content - .onDisappear { - guard !hasDisappeared else { return } - hasDisappeared = true - onFirstDisappearAction() - } - } -} - extension View { func onFirstAppear(_ onFirstAppearAction: @escaping () -> Void ) -> some View { return modifier(OnFirstAppearModifier(onFirstAppearAction)) } - func onFirstDisappear(_ onFirstDisappearAction: @escaping () -> Void ) -> some View { - return modifier(OnFirstDisappearModifier(onFirstDisappearAction)) - } - } diff --git a/DuckDuckGo/Subscription/ViewModel/SubscriptionEmailViewModel.swift b/DuckDuckGo/Subscription/ViewModel/SubscriptionEmailViewModel.swift index dc46e7e153..bf101eed74 100644 --- a/DuckDuckGo/Subscription/ViewModel/SubscriptionEmailViewModel.swift +++ b/DuckDuckGo/Subscription/ViewModel/SubscriptionEmailViewModel.swift @@ -32,9 +32,9 @@ final class SubscriptionEmailViewModel: ObservableObject { let subFeature: SubscriptionPagesUseSubscriptionFeature private var canGoBackCancellable: AnyCancellable? + private var urlCancellable: AnyCancellable? var emailURL = URL.activateSubscriptionViaEmail - var viewTitle = UserText.subscriptionActivateEmailTitle var webViewModel: AsyncHeadlessWebViewViewModel enum SelectedFeature { @@ -50,10 +50,12 @@ final class SubscriptionEmailViewModel: ObservableObject { var canNavigateBack: Bool = false var shouldDismissView: Bool = false var subscriptionActive: Bool = false + var isWelcomePageVisible: Bool = false var backButtonTitle: String = UserText.backButtonTitle var selectedFeature: SelectedFeature = .none var shouldPopToSubscriptionSettings: Bool = false var shouldPopToAppSettings: Bool = false + var viewTitle = UserText.subscriptionActivateEmailTitle } // Read only View State - Should only be modified from the VM @@ -68,6 +70,11 @@ final class SubscriptionEmailViewModel: ObservableObject { } private var cancellables = Set() + + private var isWelcomePageOrSuccessPage: Bool { + webViewModel.url?.forComparison() == URL.subscriptionActivateSuccess.forComparison() || + webViewModel.url?.forComparison() == URL.subscriptionPurchase.forComparison() + } init(userScript: SubscriptionPagesUserScript, subFeature: SubscriptionPagesUseSubscriptionFeature, @@ -103,33 +110,34 @@ final class SubscriptionEmailViewModel: ObservableObject { @MainActor func onFirstAppear() { - setupObservers() - if accountManager.isUserAuthenticated { + setupWebObservers() + setupFeatureObservers() + } + + private func cleanUp() { + canGoBackCancellable?.cancel() + subFeature.cleanup() + cancellables.removeAll() + } + + func onAppear() { + state.shouldDismissView = false + // If the user is Authenticated & not in the Welcome page + if accountManager.isUserAuthenticated && !isWelcomePageOrSuccessPage { // If user is authenticated, we want to "Add or manage email" instead of activating emailURL = accountManager.email == nil ? URL.addEmailToSubscription : URL.manageSubscriptionEmail - viewTitle = accountManager.email == nil ? UserText.subscriptionRestoreAddEmailTitle : UserText.subscriptionManageEmailTitle + state.viewTitle = accountManager.email == nil ? UserText.subscriptionRestoreAddEmailTitle : UserText.subscriptionManageEmailTitle // Also we assume subscription requires managing, and not activation state.managingSubscriptionEmail = true } - if webViewModel.url?.forComparison() != URL.subscriptionActivateSuccess { + // Load the Email Management URL unless the user has activated a subscription or is on the welcome page + if !isWelcomePageOrSuccessPage { self.webViewModel.navigationCoordinator.navigateTo(url: self.emailURL) } } - func onFirstDisappear() { - cancellables.removeAll() - canGoBackCancellable = nil - } - - private func setupObservers() { - - // Webview navigation - canGoBackCancellable = webViewModel.$canGoBack - .receive(on: DispatchQueue.main) - .sink { [weak self] value in - self?.updateBackButton(canNavigateBack: value) - } + private func setupFeatureObservers() { // Feature Callback subFeature.onSetSubscription = { @@ -172,7 +180,26 @@ final class SubscriptionEmailViewModel: ObservableObject { } } .store(in: &cancellables) - + } + + private func setupWebObservers() { + + // Webview navigation + canGoBackCancellable = webViewModel.$canGoBack + .receive(on: DispatchQueue.main) + .sink { [weak self] value in + self?.updateBackButton(canNavigateBack: value) + } + + // Webview navigation + urlCancellable = webViewModel.$url + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + if self?.isWelcomePageOrSuccessPage ?? false { + self?.state.viewTitle = UserText.subscriptionTitle + } + } + webViewModel.$navigationError .receive(on: DispatchQueue.main) .sink { [weak self] error in @@ -185,17 +212,14 @@ final class SubscriptionEmailViewModel: ObservableObject { .store(in: &cancellables) } - func updateBackButton(canNavigateBack: Bool) { - - // Disable Browser navigation by default - self.state.canNavigateBack = false + private func updateBackButton(canNavigateBack: Bool) { // If the view is not Activation Success, or Welcome page, allow WebView Back Navigation - if self.webViewModel.url?.forComparison() != URL.subscriptionActivateSuccess.forComparison() && - self.webViewModel.url?.forComparison() != URL.subscriptionPurchase.forComparison() { + if !isWelcomePageOrSuccessPage { self.state.canNavigateBack = canNavigateBack self.state.backButtonTitle = UserText.backButtonTitle } else { + self.state.canNavigateBack = false self.state.backButtonTitle = UserText.settingsTitle } @@ -228,9 +252,9 @@ final class SubscriptionEmailViewModel: ObservableObject { } deinit { - cancellables.removeAll() + cleanUp() canGoBackCancellable = nil - + } } diff --git a/DuckDuckGo/Subscription/ViewModel/SubscriptionExternalLinkViewModel.swift b/DuckDuckGo/Subscription/ViewModel/SubscriptionExternalLinkViewModel.swift index f2f5feb45d..4890437265 100644 --- a/DuckDuckGo/Subscription/ViewModel/SubscriptionExternalLinkViewModel.swift +++ b/DuckDuckGo/Subscription/ViewModel/SubscriptionExternalLinkViewModel.swift @@ -56,7 +56,11 @@ final class SubscriptionExternalLinkViewModel: ObservableObject { func onFirstAppear() { Task { await setupSubscribers() } webViewModel.navigationCoordinator.navigateTo(url: url) - + } + + private func cleanUp() { + canGoBackCancellable?.cancel() + cancellables.removeAll() } @MainActor @@ -65,7 +69,8 @@ final class SubscriptionExternalLinkViewModel: ObservableObject { } deinit { - cancellables.removeAll() + cleanUp() + canGoBackCancellable = nil } } diff --git a/DuckDuckGo/Subscription/ViewModel/SubscriptionFlowViewModel.swift b/DuckDuckGo/Subscription/ViewModel/SubscriptionFlowViewModel.swift index 518a9cd0e9..ec8e188e07 100644 --- a/DuckDuckGo/Subscription/ViewModel/SubscriptionFlowViewModel.swift +++ b/DuckDuckGo/Subscription/ViewModel/SubscriptionFlowViewModel.swift @@ -32,12 +32,12 @@ final class SubscriptionFlowViewModel: ObservableObject { let subFeature: SubscriptionPagesUseSubscriptionFeature let purchaseManager: PurchaseManager var webViewModel: AsyncHeadlessWebViewViewModel - - let viewTitle = UserText.settingsPProSection + var purchaseURL = URL.subscriptionPurchase private var cancellables = Set() private var canGoBackCancellable: AnyCancellable? + private var urlCancellable: AnyCancellable? enum Constants { static let navigationBarHideThreshold = 80.0 @@ -56,6 +56,7 @@ final class SubscriptionFlowViewModel: ObservableObject { var transactionError: SubscriptionPurchaseError? var shouldHideBackButton = false var selectedFeature: SelectedFeature = .none + var viewTitle: String = UserText.subscriptionTitle } // Read only View State - Should only be modified from the VM @@ -217,10 +218,25 @@ final class SubscriptionFlowViewModel: ObservableObject { } } } + + urlCancellable = webViewModel.$url + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let strongSelf = self else { return } + strongSelf.state.canNavigateBack = false + guard let currentURL = self?.webViewModel.url else { return } + if currentURL.forComparison() == URL.addEmailToSubscription.forComparison() || + currentURL.forComparison() == URL.addEmailToSubscriptionSuccess.forComparison() || + currentURL.forComparison() == URL.addEmailToSubscriptionSuccess.forComparison() { + strongSelf.state.viewTitle = UserText.subscriptionRestoreAddEmailTitle + } else { + strongSelf.state.viewTitle = UserText.subscriptionTitle + } + } + } private func backButtonForURL(currentURL: URL) -> Bool { - print(currentURL) return currentURL.forComparison() != URL.subscriptionBaseURL.forComparison() && currentURL.forComparison() != URL.subscriptionActivateSuccess.forComparison() && currentURL.forComparison() != URL.subscriptionPurchase.forComparison() @@ -228,6 +244,7 @@ final class SubscriptionFlowViewModel: ObservableObject { private func cleanUp() { canGoBackCancellable?.cancel() + urlCancellable?.cancel() subFeature.cleanup() cancellables.removeAll() } @@ -239,6 +256,8 @@ final class SubscriptionFlowViewModel: ObservableObject { deinit { cleanUp() + canGoBackCancellable = nil + urlCancellable = nil } @MainActor @@ -253,21 +272,21 @@ final class SubscriptionFlowViewModel: ObservableObject { // MARK: - + func onAppear() { + self.state.selectedFeature = .none + } + func onFirstAppear() async { DispatchQueue.main.async { self.resetState() } - await self.setupTransactionObserver() - await self .setupWebViewObservers() - if webViewModel.url == nil { + if webViewModel.url != URL.subscriptionPurchase.forComparison() { self.webViewModel.navigationCoordinator.navigateTo(url: self.purchaseURL) } + await self.setupTransactionObserver() + await self.setupWebViewObservers() Pixel.fire(pixel: .privacyProOfferScreenImpression) } - - func onFirstDisappear() async { - cleanUp() - } @MainActor func restoreAppstoreTransaction() { @@ -298,3 +317,16 @@ final class SubscriptionFlowViewModel: ObservableObject { } #endif + +// TODO: Move to BSK later +private extension URL { + + static var addEmailToSubscriptionSuccess: URL { + subscriptionBaseURL.appendingPathComponent("add-email/success") + } + + static var addEmailToSubscriptionOTP: URL { + subscriptionBaseURL.appendingPathComponent("add-email/otp") + } + +} diff --git a/DuckDuckGo/Subscription/ViewModel/SubscriptionITPViewModel.swift b/DuckDuckGo/Subscription/ViewModel/SubscriptionITPViewModel.swift index 6efa74f56d..4ff55c52a7 100644 --- a/DuckDuckGo/Subscription/ViewModel/SubscriptionITPViewModel.swift +++ b/DuckDuckGo/Subscription/ViewModel/SubscriptionITPViewModel.swift @@ -139,6 +139,11 @@ final class SubscriptionITPViewModel: ObservableObject { Pixel.fire(pixel: .privacyProIdentityRestorationSettings) } + private func cleanUp() { + canGoBackCancellable?.cancel() + cancellables.removeAll() + } + private func downloadAttachment(from url: URL) async { if let (temporaryURL, _) = try? await URLSession.shared.download(from: url) { let fileManager = FileManager.default @@ -181,7 +186,8 @@ final class SubscriptionITPViewModel: ObservableObject { } deinit { - cancellables.removeAll() + cleanUp() + canGoBackCancellable = nil self.userScript = nil self.subFeature = nil } diff --git a/DuckDuckGo/Subscription/ViewModel/SubscriptionRestoreViewModel.swift b/DuckDuckGo/Subscription/ViewModel/SubscriptionRestoreViewModel.swift index 3a56f4cc29..8d1482f65c 100644 --- a/DuckDuckGo/Subscription/ViewModel/SubscriptionRestoreViewModel.swift +++ b/DuckDuckGo/Subscription/ViewModel/SubscriptionRestoreViewModel.swift @@ -69,28 +69,30 @@ final class SubscriptionRestoreViewModel: ObservableObject { self.state.isAddingDevice = false } - func onFirstAppear() async { + func onAppear() { DispatchQueue.main.async { self.resetState() } - await setupContent() - await setupTransactionObserver() + Task { await setupContent() } } - - func onFirstDisappear() async { - cleanUp() + + func onFirstAppear() async { + Pixel.fire(pixel: .privacyProSettingsAddDevice) + await setupTransactionObserver() } private func cleanUp() { + subFeature.cleanup() cancellables.removeAll() } + private func setupContent() async { if state.isAddingDevice { DispatchQueue.main.async { self.state.isLoading = true } - Pixel.fire(pixel: .privacyProSettingsAddDevice) + guard let token = accountManager.accessToken else { return } switch await accountManager.fetchAccountDetails(with: token) { case .success(let details): @@ -198,6 +200,10 @@ final class SubscriptionRestoreViewModel: ObservableObject { state.shouldDismissView = true } + deinit { + cleanUp() + } + } #endif diff --git a/DuckDuckGo/Subscription/Views/DaxLogoNavbarTitle.swift b/DuckDuckGo/Subscription/Views/DaxLogoNavbarTitle.swift new file mode 100644 index 0000000000..b6e7a149c3 --- /dev/null +++ b/DuckDuckGo/Subscription/Views/DaxLogoNavbarTitle.swift @@ -0,0 +1,39 @@ +// +// DaxLogoNavbarTitle.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 SwiftUI + +struct DaxLogoNavbarTitle: View { + + enum Constants { + static let daxLogoSize: CGFloat = 24.0 + static let daxLogo = "Home" + } + + var body: some View { + HStack { + Image(Constants.daxLogo) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: Constants.daxLogoSize, height: Constants.daxLogoSize) + Text(UserText.subscriptionTitle).daxBodyRegular() + } + } + +} diff --git a/DuckDuckGo/Subscription/Views/SubscriptionEmailView.swift b/DuckDuckGo/Subscription/Views/SubscriptionEmailView.swift index 6fc62f7643..ce23b945ea 100644 --- a/DuckDuckGo/Subscription/Views/SubscriptionEmailView.swift +++ b/DuckDuckGo/Subscription/Views/SubscriptionEmailView.swift @@ -60,6 +60,9 @@ struct SubscriptionEmailView: View { ToolbarItemGroup(placement: .navigationBarLeading) { browserBackButton } + ToolbarItemGroup(placement: .principal) { + daxLogoToolbarItem + } } .navigationBarTitleDisplayMode(.inline) .navigationViewStyle(.stack) @@ -126,13 +129,17 @@ struct SubscriptionEmailView: View { } } - .navigationTitle(viewModel.viewTitle) + .navigationTitle(viewModel.state.viewTitle) .onFirstAppear { setUpAppearances() viewModel.onFirstAppear() } + .onAppear { + viewModel.onAppear() + } + } // MARK: - @@ -158,6 +165,13 @@ struct SubscriptionEmailView: View { }) } + @ViewBuilder + private var daxLogoToolbarItem: some View { + if viewModel.state.viewTitle == UserText.subscriptionTitle { + DaxLogoNavbarTitle() + } + } + private func setUpAppearances() { let navAppearance = UINavigationBar.appearance() navAppearance.backgroundColor = UIColor(designSystemColor: .surface) diff --git a/DuckDuckGo/Subscription/Views/SubscriptionFlowView.swift b/DuckDuckGo/Subscription/Views/SubscriptionFlowView.swift index 3f8d3fe006..23f206d79f 100644 --- a/DuckDuckGo/Subscription/Views/SubscriptionFlowView.swift +++ b/DuckDuckGo/Subscription/Views/SubscriptionFlowView.swift @@ -41,8 +41,6 @@ struct SubscriptionFlowView: View { @State private var isPresentingError: Bool = false enum Constants { - static let daxLogo = "Home" - static let daxLogoSize: CGFloat = 24.0 static let empty = "" static let navButtonPadding: CGFloat = 20.0 static let backButtonImage = "chevron.left" @@ -74,12 +72,10 @@ struct SubscriptionFlowView: View { backButton } ToolbarItem(placement: .principal) { - HStack { - Image(Constants.daxLogo) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: Constants.daxLogoSize, height: Constants.daxLogoSize) - Text(viewModel.viewTitle).daxBodyRegular() + if viewModel.state.viewTitle == UserText.subscriptionTitle { + DaxLogoNavbarTitle() + } else { + Text(viewModel.state.viewTitle).bold() } } } @@ -175,13 +171,9 @@ struct SubscriptionFlowView: View { setUpAppearances() Task { await viewModel.onFirstAppear() } } - - .onFirstDisappear { - Task { await viewModel.onFirstDisappear() } - } - + .onAppear { - Task { await viewModel.onFirstAppear() } + viewModel.onAppear() } .alert(isPresented: $isPresentingError) { diff --git a/DuckDuckGo/Subscription/Views/SubscriptionITPView.swift b/DuckDuckGo/Subscription/Views/SubscriptionITPView.swift index 5904854dc2..490d68b1fb 100644 --- a/DuckDuckGo/Subscription/Views/SubscriptionITPView.swift +++ b/DuckDuckGo/Subscription/Views/SubscriptionITPView.swift @@ -42,8 +42,6 @@ struct SubscriptionITPView: View { @State private var isShowingActivityView = false enum Constants { - static let daxLogo = "Home" - static let daxLogoSize: CGFloat = 24.0 static let empty = "" static let navButtonPadding: CGFloat = 20.0 static let backButtonImage = "chevron.left" @@ -59,13 +57,7 @@ struct SubscriptionITPView: View { backButton } ToolbarItem(placement: .principal) { - HStack { - Image(Constants.daxLogo) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: Constants.daxLogoSize, height: Constants.daxLogoSize) - Text(viewModel.viewTitle).daxBodyRegular() - } + DaxLogoNavbarTitle() } ToolbarItem(placement: .navigationBarTrailing) { shareButton diff --git a/DuckDuckGo/Subscription/Views/SubscriptionPIRView.swift b/DuckDuckGo/Subscription/Views/SubscriptionPIRView.swift index 9756c040ca..b6f97b1afe 100644 --- a/DuckDuckGo/Subscription/Views/SubscriptionPIRView.swift +++ b/DuckDuckGo/Subscription/Views/SubscriptionPIRView.swift @@ -33,8 +33,6 @@ struct SubscriptionPIRView: View { @State private var isShowingMacView = false enum Constants { - static let daxLogo = "Home" - static let daxLogoSize: CGFloat = 24.0 static let empty = "" static let navButtonPadding: CGFloat = 20.0 static let lightMask: [Color] = [Color.init(0xFFFFFF, alpha: 0), Color.init(0xFFFFFF, alpha: 0)] @@ -62,13 +60,7 @@ struct SubscriptionPIRView: View { } .toolbar { ToolbarItem(placement: .principal) { - HStack { - Image(Constants.daxLogo) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: Constants.daxLogoSize, height: Constants.daxLogoSize) - Text(viewModel.viewTitle).daxBodyRegular() - } + DaxLogoNavbarTitle() } } .onFirstAppear { diff --git a/DuckDuckGo/Subscription/Views/SubscriptionRestoreView.swift b/DuckDuckGo/Subscription/Views/SubscriptionRestoreView.swift index f010b8aef0..a51a5d6876 100644 --- a/DuckDuckGo/Subscription/Views/SubscriptionRestoreView.swift +++ b/DuckDuckGo/Subscription/Views/SubscriptionRestoreView.swift @@ -151,10 +151,11 @@ struct SubscriptionRestoreView: View { Task { await viewModel.onFirstAppear() } setUpAppearances() } + + .onAppear { + viewModel.onAppear() + } - .onFirstDisappear { - Task { await viewModel.onFirstDisappear() } - } } diff --git a/DuckDuckGo/Subscription/Views/SubscriptionSettingsView.swift b/DuckDuckGo/Subscription/Views/SubscriptionSettingsView.swift index e93ca2e55f..f3bf14d671 100644 --- a/DuckDuckGo/Subscription/Views/SubscriptionSettingsView.swift +++ b/DuckDuckGo/Subscription/Views/SubscriptionSettingsView.swift @@ -38,9 +38,9 @@ struct SubscriptionSettingsView: View { var body: some View { optionsView - .onAppear(perform: { + .onFirstAppear { Pixel.fire(pixel: .privacyProSubscriptionSettings, debounce: 1) - }) + } .navigationBarTitleDisplayMode(.inline) } diff --git a/DuckDuckGo/TabURLInterceptor.swift b/DuckDuckGo/TabURLInterceptor.swift new file mode 100644 index 0000000000..600b18e2fb --- /dev/null +++ b/DuckDuckGo/TabURLInterceptor.swift @@ -0,0 +1,99 @@ +// +// TabURLInterceptor.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 BrowserServicesKit +import Common +import Subscription + +enum InterceptedURL: String { + case privacyPro +} + +struct InterceptedURLInfo { + let id: InterceptedURL + let path: String +} + +protocol TabURLInterceptor { + func allowsNavigatingTo(url: URL) -> Bool +} + +final class TabURLInterceptorDefault: TabURLInterceptor { + + + static let interceptedURLs: [InterceptedURLInfo] = [ + InterceptedURLInfo(id: .privacyPro, path: "/pro") + ] + + func allowsNavigatingTo(url: URL) -> Bool { + + if !url.isPart(ofDomain: "duckduckgo.com") { + return true + } + + guard let components = normalizeScheme(url.absoluteString) else { + return true + } + + guard let matchingURL = urlToIntercept(path: components.path) else { + return true + } + + return Self.handleURLInterception(url: matchingURL.id) + + } +} + +extension TabURLInterceptorDefault { + + private func urlToIntercept(path: String) -> InterceptedURLInfo? { + let results = Self.interceptedURLs.filter { $0.path == path } + return results.first + } + + private func normalizeScheme(_ rawUrl: String) -> URLComponents? { + if !rawUrl.starts(with: URL.URLProtocol.https.scheme) && + !rawUrl.starts(with: URL.URLProtocol.http.scheme) && + rawUrl.contains("://") { + return nil + } + let noScheme = rawUrl.dropping(prefix: URL.URLProtocol.https.scheme).dropping(prefix: URL.URLProtocol.http.scheme) + + return URLComponents(string: "\(URL.URLProtocol.https.scheme)\(noScheme)") + } + + private static func handleURLInterception(url: InterceptedURL) -> Bool { + switch url { + + // Opens the Privacy Pro Subscription Purchase page (if user can purchase) + case .privacyPro: + if SubscriptionPurchaseEnvironment.canPurchase { + NotificationCenter.default.post(name: .urlInterceptPrivacyPro, object: nil) + return false + } + } + return true + + } +} + +extension NSNotification.Name { + static let urlInterceptPrivacyPro: NSNotification.Name = Notification.Name(rawValue: "com.duckduckgo.notification.urlInterceptPrivacyPro") +} diff --git a/DuckDuckGo/TabViewController.swift b/DuckDuckGo/TabViewController.swift index 5429c52948..72ab21f196 100644 --- a/DuckDuckGo/TabViewController.swift +++ b/DuckDuckGo/TabViewController.swift @@ -127,6 +127,9 @@ class TabViewController: UIViewController { private var trackersInfoWorkItem: DispatchWorkItem? + private var tabURLInterceptor: TabURLInterceptor = TabURLInterceptorDefault() + private var currentlyLoadedURL: URL? + #if NETWORK_PROTECTION private let netPConnectionObserver = ConnectionStatusObserverThroughSession() private var netPConnectionObserverCancellable: AnyCancellable? @@ -284,6 +287,8 @@ class TabViewController: UIViewController { } private let rulesCompilationMonitor = RulesCompilationMonitor.shared + + private var lastRenderedURL: URL? static func loadFromStoryboard(model: Tab, appSettings: AppSettings = AppDependencyProvider.shared.appSettings, @@ -1092,7 +1097,7 @@ extension TabViewController: WKNavigationDelegate { func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) { - + let mimeType = MIMEType(from: navigationResponse.response.mimeType) let httpResponse = navigationResponse.response as? HTTPURLResponse @@ -1154,6 +1159,7 @@ extension TabViewController: WKNavigationDelegate { func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { lastError = nil + lastRenderedURL = webView.url cancelTrackerNetworksAnimation() shouldReloadOnError = false hideErrorMessage() @@ -1165,7 +1171,7 @@ extension TabViewController: WKNavigationDelegate { } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - + self.currentlyLoadedURL = webView.url adClickAttributionDetection.onDidFinishNavigation(url: webView.url) adClickAttributionLogic.onDidFinishNavigation(host: webView.url?.host) hideProgressIndicator() @@ -1394,7 +1400,20 @@ extension TabViewController: WKNavigationDelegate { func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { - + + if let url = navigationAction.request.url { + if !tabURLInterceptor.allowsNavigatingTo(url: url) { + decisionHandler(.cancel) + // If there is history or a page loaded keep the tab open + if self.currentlyLoadedURL != nil { + refresh() + } else { + delegate?.tabDidRequestClose(self) + } + return + } + } + if let url = navigationAction.request.url, !url.isDuckDuckGoSearch, true == shouldWaitUntilContentBlockingIsLoaded({ [weak self, webView /* decision handler must be called */] in diff --git a/DuckDuckGoTests/TabURLInterceptorTests.swift b/DuckDuckGoTests/TabURLInterceptorTests.swift new file mode 100644 index 0000000000..b96e78ed9e --- /dev/null +++ b/DuckDuckGoTests/TabURLInterceptorTests.swift @@ -0,0 +1,65 @@ +// +// TabURLInterceptorTests.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 XCTest +import Subscription +@testable import DuckDuckGo + +class TabURLInterceptorDefaultTests: XCTestCase { + + var urlInterceptor: TabURLInterceptorDefault! + + override func setUp() { + super.setUp() + // Simulate purchase allowance + SubscriptionPurchaseEnvironment.canPurchase = true + urlInterceptor = TabURLInterceptorDefault() + } + + override func tearDown() { + urlInterceptor = nil + super.tearDown() + } + + func testAllowsNavigationForNonDuckDuckGoDomain() { + let url = URL(string: "https://www.example.com")! + XCTAssertTrue(urlInterceptor.allowsNavigatingTo(url: url)) + } + + func testAllowsNavigationForUninterceptedDuckDuckGoPath() { + let url = URL(string: "https://duckduckgo.com/about")! + XCTAssertTrue(urlInterceptor.allowsNavigatingTo(url: url)) + } + + func testNotificationForInterceptedPrivacyProPath() { + let expectation = self.expectation(forNotification: .urlInterceptPrivacyPro, object: nil, handler: nil) + + let url = URL(string: "https://duckduckgo.com/pro")! + let canNavigate = urlInterceptor.allowsNavigatingTo(url: url) + + // Fail if no note is posted + XCTAssertFalse(canNavigate) + + waitForExpectations(timeout: 1) { error in + if let error = error { + XCTFail("Notification expectation failed: \(error)") + } + } + } +}