diff --git a/Core/PixelEvent.swift b/Core/PixelEvent.swift index 8583a3480e..91ef116fc1 100644 --- a/Core/PixelEvent.swift +++ b/Core/PixelEvent.swift @@ -639,6 +639,7 @@ extension Pixel { case privacyProVPNAccessRevokedDialogShown case privacyProVPNBetaStoppedWhenPrivacyProEnabled case privacyProTransactionProgressNotHiddenAfter60s + case privacyProSuccessfulSubscriptionAttribution // MARK: Pixel Experiment case pixelExperimentEnrollment @@ -1308,6 +1309,7 @@ extension Pixel.Event { case .privacyProSubscriptionManagementPlanBilling: return "m_privacy-pro_settings_change-plan-or-billing_click" case .privacyProSubscriptionManagementRemoval: return "m_privacy-pro_settings_remove-from-device_click" case .privacyProTransactionProgressNotHiddenAfter60s: return "m_privacy-pro_progress_not_hidden_after_60s" + case .privacyProSuccessfulSubscriptionAttribution: return "m_subscribe" // MARK: Pixel Experiment case .pixelExperimentEnrollment: return "pixel_experiment_enrollment" diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index dd86541a7e..514cb38846 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -578,7 +578,10 @@ 98F3A1D8217B37010011A0D4 /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F3A1D7217B37010011A0D4 /* Theme.swift */; }; 98F6EA472863124100720957 /* ContentBlockerRulesLists.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F6EA462863124100720957 /* ContentBlockerRulesLists.swift */; }; 98F78B8E22419093007CACF4 /* ThemableNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F78B8D22419093007CACF4 /* ThemableNavigationController.swift */; }; + 9F2510142BF5809E0096DB16 /* SubscriptionFlowViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F2510132BF5809E0096DB16 /* SubscriptionFlowViewModelTests.swift */; }; 9F8FE9492BAE50E50071E372 /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 9F8FE9482BAE50E50071E372 /* Lottie */; }; + 9FA5E44B2BF1AF3400BDEF02 /* SubscriptionContainerViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FA5E44A2BF1AF3400BDEF02 /* SubscriptionContainerViewFactory.swift */; }; + 9FA5E44E2BF1B16400BDEF02 /* SubscriptionContainerViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FA5E44D2BF1B16400BDEF02 /* SubscriptionContainerViewModelTests.swift */; }; AA3D854523D9942200788410 /* AppIconSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA3D854423D9942200788410 /* AppIconSettingsViewController.swift */; }; AA3D854723D9E88E00788410 /* AppIconSettingsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA3D854623D9E88E00788410 /* AppIconSettingsCell.swift */; }; AA3D854923DA1DFB00788410 /* AppIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA3D854823DA1DFB00788410 /* AppIcon.swift */; }; @@ -2212,6 +2215,9 @@ 98F3A1D7217B37010011A0D4 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = ""; }; 98F6EA462863124100720957 /* ContentBlockerRulesLists.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContentBlockerRulesLists.swift; sourceTree = ""; }; 98F78B8D22419093007CACF4 /* ThemableNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemableNavigationController.swift; sourceTree = ""; }; + 9F2510132BF5809E0096DB16 /* SubscriptionFlowViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionFlowViewModelTests.swift; sourceTree = ""; }; + 9FA5E44A2BF1AF3400BDEF02 /* SubscriptionContainerViewFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionContainerViewFactory.swift; sourceTree = ""; }; + 9FA5E44D2BF1B16400BDEF02 /* SubscriptionContainerViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionContainerViewModelTests.swift; sourceTree = ""; }; AA3D854423D9942200788410 /* AppIconSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconSettingsViewController.swift; sourceTree = ""; }; AA3D854623D9E88E00788410 /* AppIconSettingsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconSettingsCell.swift; sourceTree = ""; }; AA3D854823DA1DFB00788410 /* AppIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIcon.swift; sourceTree = ""; }; @@ -4087,6 +4093,15 @@ name = Themes; sourceTree = ""; }; + 9FA5E44C2BF1B14100BDEF02 /* Subscription */ = { + isa = PBXGroup; + children = ( + 9FA5E44D2BF1B16400BDEF02 /* SubscriptionContainerViewModelTests.swift */, + 9F2510132BF5809E0096DB16 /* SubscriptionFlowViewModelTests.swift */, + ); + name = Subscription; + sourceTree = ""; + }; AA4D6A8023DE4973007E8790 /* AppIcon */ = { isa = PBXGroup; children = ( @@ -4463,6 +4478,7 @@ children = ( D664C7AD2B289AA000CBFA76 /* PurchaseInProgressView.swift */, D66F683C2BB333C100AE93E2 /* SubscriptionContainerView.swift */, + 9FA5E44A2BF1AF3400BDEF02 /* SubscriptionContainerViewFactory.swift */, D664C7AE2B289AA000CBFA76 /* SubscriptionFlowView.swift */, D68DF81B2B58302E0023DBEA /* SubscriptionRestoreView.swift */, D64648AC2B59936B0033090B /* SubscriptionEmailView.swift */, @@ -4824,6 +4840,7 @@ F1D477C71F2139210031ED49 /* OmniBar */, C1B7B52E28944DDC0098FD6A /* RemoteMessaging */, 98EA2C3F218BB5140023E1DC /* Settings */, + 9FA5E44C2BF1B14100BDEF02 /* Subscription */, F13B4BF71F18C9E800814661 /* Tabs */, 98EA2C3A218B9A880023E1DC /* Themes */, F12790DD1EBBDDF3001D3AEC /* Tutorials */, @@ -6492,6 +6509,7 @@ 1E8AD1D527C2E22900ABA377 /* DownloadsListSectionViewModel.swift in Sources */, EE0798C52B179936000A4F64 /* NetworkProtectionVPNCountryLabelsModel.swift in Sources */, 31584616281AFB46004ADB8B /* AutofillLoginDetailsViewController.swift in Sources */, + 9FA5E44B2BF1AF3400BDEF02 /* SubscriptionContainerViewFactory.swift in Sources */, C1F341C72A6924100032057B /* EmailAddressPromptViewModel.swift in Sources */, F47E53D9250A97330037C686 /* OnboardingDefaultBroswerViewController.swift in Sources */, F13B4BD51F183B3600814661 /* TabsModelPersistenceExtension.swift in Sources */, @@ -6831,6 +6849,7 @@ 83EDCC411F86B89C005CDFCD /* StatisticsLoaderTests.swift in Sources */, C14882E327F20D9A00D59F0C /* BookmarksExporterTests.swift in Sources */, 85C29708247BDD060063A335 /* DaxDialogsBrowsingSpecTests.swift in Sources */, + 9FA5E44E2BF1B16400BDEF02 /* SubscriptionContainerViewModelTests.swift in Sources */, 85BA58581F34F72F00C6E8CA /* AppUserDefaultsTests.swift in Sources */, F1134EBC1F40D45700B73467 /* MockStatisticsStore.swift in Sources */, 31C138AC27A403CB00FFD4B2 /* DownloadManagerTests.swift in Sources */, @@ -6884,6 +6903,7 @@ 1E8146AD28C8ABF000D1AF63 /* TrackerAnimationLogicTests.swift in Sources */, C1CDA31E2AFBF811006D1476 /* AutofillNeverPromptWebsitesManagerTests.swift in Sources */, B6AD9E3A28D456820019CDE9 /* PrivacyConfigurationManagerMock.swift in Sources */, + 9F2510142BF5809E0096DB16 /* SubscriptionFlowViewModelTests.swift in Sources */, F189AED71F18F6DE001EBAE1 /* TabTests.swift in Sources */, F13B4BFB1F18E3D900814661 /* TabsModelPersistenceExtensionTests.swift in Sources */, C12B6E7C2BED69C100050D93 /* AutofillPixelReporterTests.swift in Sources */, @@ -9834,7 +9854,7 @@ repositoryURL = "https://github.com/DuckDuckGo/BrowserServicesKit"; requirement = { kind = exactVersion; - version = "144.0.7-3"; + version = "144.0.7-4"; }; }; 9F8FE9472BAE50E50071E372 /* XCRemoteSwiftPackageReference "lottie-spm" */ = { diff --git a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 5dfc9aac09..0c7edb9995 100644 --- a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/DuckDuckGo/BrowserServicesKit", "state" : { - "revision" : "ada5f68970f098b3230dbd80a25cd048a606ac12", - "version" : "144.0.7-3" + "revision" : "43db1d59455246547fc4ea3998f07751dfa77166", + "version" : "144.0.7-4" } }, { @@ -138,7 +138,7 @@ { "identity" : "swift-argument-parser", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-argument-parser.git", + "location" : "https://github.com/apple/swift-argument-parser", "state" : { "revision" : "46989693916f56d1186bd59ac15124caef896560", "version" : "1.3.1" @@ -183,7 +183,7 @@ { "identity" : "trackerradarkit", "kind" : "remoteSourceControl", - "location" : "https://github.com/duckduckgo/TrackerRadarKit", + "location" : "https://github.com/duckduckgo/TrackerRadarKit.git", "state" : { "revision" : "6c84fd19139414fc0edbf9673ade06e532a564f0", "version" : "2.0.0" diff --git a/DuckDuckGo/MainViewController+Segues.swift b/DuckDuckGo/MainViewController+Segues.swift index ac14cceef7..68f248fc60 100644 --- a/DuckDuckGo/MainViewController+Segues.swift +++ b/DuckDuckGo/MainViewController+Segues.swift @@ -206,7 +206,7 @@ extension MainViewController { os_log(#function, log: .generalLog, type: .debug) hideAllHighlightsIfNeeded() launchSettings { - $0.triggerDeepLinkNavigation(to: .subscriptionFlow) + $0.triggerDeepLinkNavigation(to: .subscriptionFlow()) } } diff --git a/DuckDuckGo/MainViewController.swift b/DuckDuckGo/MainViewController.swift index 2d9f5230be..b2a28045a4 100644 --- a/DuckDuckGo/MainViewController.swift +++ b/DuckDuckGo/MainViewController.swift @@ -1371,7 +1371,13 @@ class MainViewController: UIViewController { .sink { [weak self] notification in switch notification.name { case .urlInterceptPrivacyPro: - self?.launchSettings(deepLinkTarget: .subscriptionFlow) + let deepLinkTarget: SettingsViewModel.SettingsDeepLinkSection + if let origin = notification.userInfo?[AttributionParameter.origin] as? String { + deepLinkTarget = .subscriptionFlow(origin: origin) + } else { + deepLinkTarget = .subscriptionFlow() + } + self?.launchSettings(deepLinkTarget: deepLinkTarget) default: return } diff --git a/DuckDuckGo/SettingsRootView.swift b/DuckDuckGo/SettingsRootView.swift index 1ff3260f70..f9b27c8b98 100644 --- a/DuckDuckGo/SettingsRootView.swift +++ b/DuckDuckGo/SettingsRootView.swift @@ -114,10 +114,10 @@ struct SettingsRootView: View { SubscriptionPIRView() case .itr: SubscriptionITPView() - case .subscriptionFlow: - SubscriptionContainerView(currentView: .subscribe).environmentObject(subscriptionNavigationCoordinator) + case let .subscriptionFlow(origin): + SubscriptionContainerViewFactory.makeSubscribeFlow(origin: origin, navigationCoordinator: subscriptionNavigationCoordinator) case .subscriptionRestoreFlow: - SubscriptionContainerView(currentView: .restore).environmentObject(subscriptionNavigationCoordinator) + SubscriptionContainerViewFactory.makeRestoreFlow(navigationCoordinator: subscriptionNavigationCoordinator) default: EmptyView() } diff --git a/DuckDuckGo/SettingsSubscriptionView.swift b/DuckDuckGo/SettingsSubscriptionView.swift index 2f47a54247..b5f6e50cb4 100644 --- a/DuckDuckGo/SettingsSubscriptionView.swift +++ b/DuckDuckGo/SettingsSubscriptionView.swift @@ -86,12 +86,12 @@ struct SettingsSubscriptionView: View { Group { SettingsCustomCell(content: { subscriptionDescriptionView }) - let subscribeView = SubscriptionContainerView(currentView: .subscribe) + let subscribeView = SubscriptionContainerViewFactory.makeSubscribeFlow( + origin: nil, + navigationCoordinator: subscriptionNavigationCoordinator + ).navigationViewStyle(.stack) + let restoreView = SubscriptionContainerViewFactory.makeRestoreFlow(navigationCoordinator: subscriptionNavigationCoordinator) .navigationViewStyle(.stack) - .environmentObject(subscriptionNavigationCoordinator) - let restoreView = SubscriptionContainerView(currentView: .restore) - .navigationViewStyle(.stack) - .environmentObject(subscriptionNavigationCoordinator) .onFirstAppear { Pixel.fire(pixel: .privacyProRestorePurchaseClick) } @@ -122,9 +122,10 @@ struct SettingsSubscriptionView: View { } }) - let subscribeView = SubscriptionContainerView(currentView: .subscribe) - .navigationViewStyle(.stack) - .environmentObject(subscriptionNavigationCoordinator) + let subscribeView = SubscriptionContainerViewFactory.makeSubscribeFlow( + origin: nil, + navigationCoordinator: subscriptionNavigationCoordinator + ).navigationViewStyle(.stack) NavigationLink( destination: subscribeView, isActive: $isShowingSubscribeFlow, diff --git a/DuckDuckGo/SettingsView.swift b/DuckDuckGo/SettingsView.swift index 4b41928424..e768be3df4 100644 --- a/DuckDuckGo/SettingsView.swift +++ b/DuckDuckGo/SettingsView.swift @@ -123,10 +123,10 @@ struct SettingsView: View { SubscriptionPIRView() case .itr: SubscriptionITPView() - case .subscriptionFlow: - SubscriptionContainerView(currentView: .subscribe).environmentObject(subscriptionNavigationCoordinator) + case let .subscriptionFlow(origin): + SubscriptionContainerViewFactory.makeSubscribeFlow(origin: origin, navigationCoordinator: subscriptionNavigationCoordinator) case .subscriptionRestoreFlow: - SubscriptionContainerView(currentView: .restore).environmentObject(subscriptionNavigationCoordinator) + SubscriptionContainerViewFactory.makeRestoreFlow(navigationCoordinator: subscriptionNavigationCoordinator) default: EmptyView() } diff --git a/DuckDuckGo/SettingsViewModel.swift b/DuckDuckGo/SettingsViewModel.swift index d40504e820..e6ba9f645b 100644 --- a/DuckDuckGo/SettingsViewModel.swift +++ b/DuckDuckGo/SettingsViewModel.swift @@ -687,11 +687,11 @@ extension SettingsViewModel: AutofillLoginSettingsListViewControllerDelegate { // MARK: DeepLinks extension SettingsViewModel { - enum SettingsDeepLinkSection: Identifiable { + enum SettingsDeepLinkSection: Identifiable, Equatable { case netP case dbp case itr - case subscriptionFlow + case subscriptionFlow(origin: String? = nil) case subscriptionRestoreFlow // Add other cases as needed diff --git a/DuckDuckGo/Subscription/UserScripts/SubscriptionPagesUseSubscriptionFeature.swift b/DuckDuckGo/Subscription/UserScripts/SubscriptionPagesUseSubscriptionFeature.swift index e0a766cb37..7e953c4490 100644 --- a/DuckDuckGo/Subscription/UserScripts/SubscriptionPagesUseSubscriptionFeature.swift +++ b/DuckDuckGo/Subscription/UserScripts/SubscriptionPagesUseSubscriptionFeature.swift @@ -116,6 +116,11 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature, ObservableObjec var originalMessage: WKScriptMessage? + private let subscriptionAttributionOrigin: String? + init(subscriptionAttributionOrigin: String?) { + self.subscriptionAttributionOrigin = subscriptionAttributionOrigin + } + func with(broker: UserScriptMessageBroker) { self.broker = broker } @@ -252,6 +257,7 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature, ObservableObjec case .success(let purchaseUpdate): DailyPixel.fireDailyAndCount(pixel: .privacyProPurchaseSuccess) UniquePixel.fire(pixel: .privacyProSubscriptionActivated) + Pixel.fireAttribution(pixel: .privacyProSuccessfulSubscriptionAttribution, origin: subscriptionAttributionOrigin) setTransactionStatus(.idle) await pushPurchaseUpdate(originalMessage: message, purchaseUpdate: purchaseUpdate) case .failure: @@ -421,6 +427,24 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature, ObservableObjec onActivateSubscription = nil onBackToSettings = nil } - + +} + +private extension Pixel { + + enum AttributionParameters { + static let origin = "origin" + static let locale = "locale" + } + + static func fireAttribution(pixel: Pixel.Event, origin: String?, locale: Locale = .current) { + var parameters: [String: String] = [:] + parameters[AttributionParameters.locale] = locale.identifier + if let origin { + parameters[AttributionParameters.origin] = origin + } + Self.fire(pixel: pixel, withAdditionalParameters: parameters) + } + } // swiftlint:enable file_length diff --git a/DuckDuckGo/Subscription/ViewModel/SubscriptionContainerViewModel.swift b/DuckDuckGo/Subscription/ViewModel/SubscriptionContainerViewModel.swift index a30d527090..49f127bba3 100644 --- a/DuckDuckGo/Subscription/ViewModel/SubscriptionContainerViewModel.swift +++ b/DuckDuckGo/Subscription/ViewModel/SubscriptionContainerViewModel.swift @@ -18,6 +18,7 @@ // import Foundation +import Subscription import Combine @available(iOS 15.0, *) @@ -31,11 +32,14 @@ final class SubscriptionContainerViewModel: ObservableObject { let email: SubscriptionEmailViewModel - init(userScript: SubscriptionPagesUserScript = SubscriptionPagesUserScript(), - subFeature: SubscriptionPagesUseSubscriptionFeature = SubscriptionPagesUseSubscriptionFeature()) { + init( + origin: String?, + userScript: SubscriptionPagesUserScript, + subFeature: SubscriptionPagesUseSubscriptionFeature + ) { self.userScript = userScript self.subFeature = subFeature - self.flow = SubscriptionFlowViewModel(userScript: userScript, subFeature: subFeature) + self.flow = SubscriptionFlowViewModel(origin: origin, userScript: userScript, subFeature: subFeature) self.restore = SubscriptionRestoreViewModel(userScript: userScript, subFeature: subFeature) self.email = SubscriptionEmailViewModel(userScript: userScript, subFeature: subFeature) } diff --git a/DuckDuckGo/Subscription/ViewModel/SubscriptionFlowViewModel.swift b/DuckDuckGo/Subscription/ViewModel/SubscriptionFlowViewModel.swift index 5aaf7ff3ce..32584af349 100644 --- a/DuckDuckGo/Subscription/ViewModel/SubscriptionFlowViewModel.swift +++ b/DuckDuckGo/Subscription/ViewModel/SubscriptionFlowViewModel.swift @@ -31,9 +31,8 @@ final class SubscriptionFlowViewModel: ObservableObject { let subFeature: SubscriptionPagesUseSubscriptionFeature let purchaseManager: PurchaseManager var webViewModel: AsyncHeadlessWebViewViewModel - - var purchaseURL = URL.subscriptionPurchase - + let purchaseURL: URL + private var cancellables = Set() private var canGoBackCancellable: AnyCancellable? private var urlCancellable: AnyCancellable? @@ -69,10 +68,16 @@ final class SubscriptionFlowViewModel: ObservableObject { allowedDomains: allowedDomains, contentBlocking: false) - init(userScript: SubscriptionPagesUserScript, + init(origin: String?, + userScript: SubscriptionPagesUserScript, subFeature: SubscriptionPagesUseSubscriptionFeature, purchaseManager: PurchaseManager = PurchaseManager.shared, selectedFeature: SettingsViewModel.SettingsDeepLinkSection? = nil) { + if let origin { + purchaseURL = URL.subscriptionPurchase.appendingParameter(name: AttributionParameter.origin, value: origin) + } else { + purchaseURL = URL.subscriptionPurchase + } self.userScript = userScript self.subFeature = subFeature self.purchaseManager = purchaseManager diff --git a/DuckDuckGo/Subscription/Views/SubscriptionContainerView.swift b/DuckDuckGo/Subscription/Views/SubscriptionContainerView.swift index 57e5822f37..df605dbcf9 100644 --- a/DuckDuckGo/Subscription/Views/SubscriptionContainerView.swift +++ b/DuckDuckGo/Subscription/Views/SubscriptionContainerView.swift @@ -36,7 +36,7 @@ struct SubscriptionContainerView: View { private let emailViewModel: SubscriptionEmailViewModel init(currentView: CurrentView, - viewModel: SubscriptionContainerViewModel = SubscriptionContainerViewModel()) { + viewModel: SubscriptionContainerViewModel) { _currentViewState = State(initialValue: currentView) self.viewModel = viewModel let userScript = viewModel.userScript diff --git a/DuckDuckGo/Subscription/Views/SubscriptionContainerViewFactory.swift b/DuckDuckGo/Subscription/Views/SubscriptionContainerViewFactory.swift new file mode 100644 index 0000000000..1d456c4ce0 --- /dev/null +++ b/DuckDuckGo/Subscription/Views/SubscriptionContainerViewFactory.swift @@ -0,0 +1,45 @@ +// +// SubscriptionContainerViewFactory.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 + +@available(iOS 15.0, *) +enum SubscriptionContainerViewFactory { + + static func makeSubscribeFlow(origin: String?, navigationCoordinator: SubscriptionNavigationCoordinator) -> some View { + let viewModel = SubscriptionContainerViewModel( + origin: origin, + userScript: SubscriptionPagesUserScript(), + subFeature: SubscriptionPagesUseSubscriptionFeature(subscriptionAttributionOrigin: origin) + ) + return SubscriptionContainerView(currentView: .subscribe, viewModel: viewModel) + .environmentObject(navigationCoordinator) + } + + static func makeRestoreFlow(navigationCoordinator: SubscriptionNavigationCoordinator) -> some View { + let viewModel = SubscriptionContainerViewModel( + origin: nil, + userScript: SubscriptionPagesUserScript(), + subFeature: SubscriptionPagesUseSubscriptionFeature(subscriptionAttributionOrigin: nil) + ) + return SubscriptionContainerView(currentView: .restore, viewModel: viewModel) + .environmentObject(navigationCoordinator) + } + +} diff --git a/DuckDuckGo/Subscription/Views/SubscriptionSettingsView.swift b/DuckDuckGo/Subscription/Views/SubscriptionSettingsView.swift index ff26ee052b..ef9ca020dd 100644 --- a/DuckDuckGo/Subscription/Views/SubscriptionSettingsView.swift +++ b/DuckDuckGo/Subscription/Views/SubscriptionSettingsView.swift @@ -121,8 +121,7 @@ struct SubscriptionSettingsView: View { private var devicesSection: some View { Section(header: Text(UserText.subscriptionManageDevices)) { - NavigationLink(destination: SubscriptionContainerView(currentView: .restore) - .environmentObject(subscriptionNavigationCoordinator), + NavigationLink(destination: SubscriptionContainerViewFactory.makeRestoreFlow(navigationCoordinator: subscriptionNavigationCoordinator), isActive: $isShowingRestoreView) { SettingsCustomCell(content: { Text(UserText.subscriptionAddDeviceButton) diff --git a/DuckDuckGo/TabURLInterceptor.swift b/DuckDuckGo/TabURLInterceptor.swift index 600b18e2fb..6726669845 100644 --- a/DuckDuckGo/TabURLInterceptor.swift +++ b/DuckDuckGo/TabURLInterceptor.swift @@ -56,8 +56,8 @@ final class TabURLInterceptorDefault: TabURLInterceptor { return true } - return Self.handleURLInterception(url: matchingURL.id) - + return Self.handleURLInterception(interceptedURL: matchingURL.id, queryItems: components.percentEncodedQueryItems) + } } @@ -79,13 +79,20 @@ extension TabURLInterceptorDefault { return URLComponents(string: "\(URL.URLProtocol.https.scheme)\(noScheme)") } - private static func handleURLInterception(url: InterceptedURL) -> Bool { - switch url { - + private static func handleURLInterception(interceptedURL: InterceptedURL, queryItems: [URLQueryItem]?) -> Bool { + switch interceptedURL { + // Opens the Privacy Pro Subscription Purchase page (if user can purchase) case .privacyPro: if SubscriptionPurchaseEnvironment.canPurchase { - NotificationCenter.default.post(name: .urlInterceptPrivacyPro, object: nil) + // If URL has an `origin` query parameter, append it to the `subscriptionPurchase` URL. + // Also forward the origin as it will need to be sent as parameter to the Pixel to track subcription attributions. + let originQueryItem = queryItems?.first(where: { $0.name == AttributionParameter.origin }) + NotificationCenter.default.post( + name: .urlInterceptPrivacyPro, + object: nil, + userInfo: [AttributionParameter.origin: originQueryItem?.value] + ) return false } } diff --git a/DuckDuckGoTests/SubscriptionContainerViewModelTests.swift b/DuckDuckGoTests/SubscriptionContainerViewModelTests.swift new file mode 100644 index 0000000000..00039d336b --- /dev/null +++ b/DuckDuckGoTests/SubscriptionContainerViewModelTests.swift @@ -0,0 +1,48 @@ +// +// SubscriptionContainerViewModelTests.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 +@testable import DuckDuckGo + +@available(iOS 15.0, *) +final class SubscriptionContainerViewModelTests: XCTestCase { + private var sut: SubscriptionContainerViewModel! + + func testWhenInitWithOriginThenSubscriptionFlowPurchaseURLHasOriginSet() { + // GIVEN + let origin = "test_origin" + let queryParameter = URLQueryItem(name: "origin", value: "test_origin") + let expectedURL = URL.subscriptionPurchase.appending(percentEncodedQueryItem: queryParameter) + + // WHEN + sut = .init(origin: origin, userScript: .init(), subFeature: .init(subscriptionAttributionOrigin: nil)) + + // THEN + XCTAssertEqual(sut.flow.purchaseURL, expectedURL) + } + + func testWhenInitWithoutOriginThenSubscriptionFlowPurchaseURLDoesNotHaveOriginSet() { + // WHEN + sut = .init(origin: nil, userScript: .init(), subFeature: .init(subscriptionAttributionOrigin: nil)) + + // THEN + XCTAssertEqual(sut.flow.purchaseURL, URL.subscriptionPurchase) + } + +} diff --git a/DuckDuckGoTests/SubscriptionFlowViewModelTests.swift b/DuckDuckGoTests/SubscriptionFlowViewModelTests.swift new file mode 100644 index 0000000000..3552d74b95 --- /dev/null +++ b/DuckDuckGoTests/SubscriptionFlowViewModelTests.swift @@ -0,0 +1,48 @@ +// +// SubscriptionFlowViewModelTests.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 +@testable import DuckDuckGo + +@available(iOS 15.0, *) +final class SubscriptionFlowViewModelTests: XCTestCase { + private var sut: SubscriptionFlowViewModel! + + func testWhenInitWithOriginThenSubscriptionFlowPurchaseURLHasOriginSet() { + // GIVEN + let origin = "test_origin" + let queryParameter = URLQueryItem(name: "origin", value: "test_origin") + let expectedURL = URL.subscriptionPurchase.appending(percentEncodedQueryItem: queryParameter) + + // WHEN + sut = .init(origin: origin, userScript: .init(), subFeature: .init(subscriptionAttributionOrigin: nil)) + + // THEN + XCTAssertEqual(sut.purchaseURL, expectedURL) + } + + func testWhenInitWithoutOriginThenSubscriptionFlowPurchaseURLDoesNotHaveOriginSet() { + // WHEN + sut = .init(origin: nil, userScript: .init(), subFeature: .init(subscriptionAttributionOrigin: nil)) + + // THEN + XCTAssertEqual(sut.purchaseURL, URL.subscriptionPurchase) + } + +} diff --git a/DuckDuckGoTests/TabURLInterceptorTests.swift b/DuckDuckGoTests/TabURLInterceptorTests.swift index b96e78ed9e..6bf3b6eee5 100644 --- a/DuckDuckGoTests/TabURLInterceptorTests.swift +++ b/DuckDuckGoTests/TabURLInterceptorTests.swift @@ -48,7 +48,7 @@ class TabURLInterceptorDefaultTests: XCTestCase { } func testNotificationForInterceptedPrivacyProPath() { - let expectation = self.expectation(forNotification: .urlInterceptPrivacyPro, object: nil, handler: nil) + _ = self.expectation(forNotification: .urlInterceptPrivacyPro, object: nil, handler: nil) let url = URL(string: "https://duckduckgo.com/pro")! let canNavigate = urlInterceptor.allowsNavigatingTo(url: url) @@ -62,4 +62,39 @@ class TabURLInterceptorDefaultTests: XCTestCase { } } } + + func testWhenURLIsPrivacyProAndHasOriginQueryParameterThenNotificationUserInfoHasOriginSet() throws { + // GIVEN + var capturedNotification: Notification? + _ = self.expectation(forNotification: .urlInterceptPrivacyPro, object: nil, handler: { notification in + capturedNotification = notification + return true + }) + let url = try XCTUnwrap(URL(string: "https://duckduckgo.com/pro?origin=test_origin")) + + // WHEN + _ = urlInterceptor.allowsNavigatingTo(url: url) + + // THEN + waitForExpectations(timeout: 1) + let origin = try XCTUnwrap(capturedNotification?.userInfo?[AttributionParameter.origin] as? String) + XCTAssertEqual(origin, "test_origin") + } + + func testWhenURLIsPrivacyProAndDoesNotHaveOriginQueryParameterThenNotificationUserInfoDoesNotHaveOriginSet() throws { + // GIVEN + var capturedNotification: Notification? + _ = self.expectation(forNotification: .urlInterceptPrivacyPro, object: nil, handler: { notification in + capturedNotification = notification + return true + }) + let url = try XCTUnwrap(URL(string: "https://duckduckgo.com/pro")) + + // WHEN + _ = urlInterceptor.allowsNavigatingTo(url: url) + + // THEN + waitForExpectations(timeout: 1) + XCTAssertNil(capturedNotification?.userInfo?[AttributionParameter.origin] as? String) + } }