From a3f289db947c26274bede8b4b58826b33c7dc258 Mon Sep 17 00:00:00 2001 From: Daniel Bernal Date: Fri, 12 Jan 2024 15:34:53 +0100 Subject: [PATCH] Manage Subscription Settings (#2323) Task/Issue URL: https://app.asana.com/0/0/1206130131426002/f Description: Integrate Subscription management options in settings Adds subscription Debug options to debug menu Minor fixes in SwiftUI Settings Base Restore Flow --- Core/UserDefaultsPropertyWrapper.swift | 2 + DuckDuckGo.xcodeproj/project.pbxproj | 4 + DuckDuckGo/AppDelegate.swift | 4 +- DuckDuckGo/Debug.storyboard | 36 ++-- DuckDuckGo/MainViewController+Segues.swift | 4 +- .../Flows/AppStore/AppStorePurchaseFlow.swift | 4 +- .../SubscriptionPurchaseEnvironment.swift | 4 +- ...scriptionPagesUseSubscriptionFeature.swift | 63 ++++--- .../ViewModel/SubscriptionFlowViewModel.swift | 33 +++- .../PrivacyPro/Views/HeadlessWebView.swift | 48 +++-- .../Views/SubscriptionFlowView.swift | 26 ++- DuckDuckGo/SettingsCell.swift | 39 ++-- DuckDuckGo/SettingsPrivacyProView.swift | 26 ++- DuckDuckGo/SettingsState.swift | 20 ++- DuckDuckGo/SettingsSyncView.swift | 2 +- DuckDuckGo/SettingsViewModel.swift | 170 ++++++++++++------ .../SubscriptionDebugViewController.swift | 128 +++++++++++-- DuckDuckGo/UserText.swift | 11 ++ DuckDuckGo/en.lproj/Localizable.strings | 18 ++ 19 files changed, 493 insertions(+), 149 deletions(-) diff --git a/Core/UserDefaultsPropertyWrapper.swift b/Core/UserDefaultsPropertyWrapper.swift index e5472423b6..192bb83d82 100644 --- a/Core/UserDefaultsPropertyWrapper.swift +++ b/Core/UserDefaultsPropertyWrapper.swift @@ -121,6 +121,8 @@ public struct UserDefaultsWrapper { case bookmarksMigrationVersion = "com.duckduckgo.ios.bookmarksMigrationVersion" case privacyConfigCustomURL = "com.duckduckgo.ios.privacyConfigCustomURL" + + case privacyProHasActiveSubscription = "com.duckduckgo.ios.privacyPro.hasActiveSubscription" } private let key: Key diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 9e0062ea81..1494183c68 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -808,6 +808,7 @@ D6E83C642B238432006C8AFB /* SettingsAboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E83C632B238432006C8AFB /* SettingsAboutView.swift */; }; D6E83C662B23936F006C8AFB /* SettingsDebugView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E83C652B23936F006C8AFB /* SettingsDebugView.swift */; }; D6E83C682B23B6A3006C8AFB /* FontSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E83C672B23B6A3006C8AFB /* FontSettings.swift */; }; + D6F93E3C2B4FFA97004C268D /* SubscriptionDebugViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F93E3B2B4FFA97004C268D /* SubscriptionDebugViewController.swift */; }; EA39B7E2268A1A35000C62CD /* privacy-reference-tests in Resources */ = {isa = PBXBuildFile; fileRef = EA39B7E1268A1A35000C62CD /* privacy-reference-tests */; }; EAB19EDA268963510015D3EA /* DomainMatchingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAB19ED9268963510015D3EA /* DomainMatchingTests.swift */; }; EE0153E12A6EABE0002A8B26 /* NetworkProtectionConvenienceInitialisers.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE0153E02A6EABE0002A8B26 /* NetworkProtectionConvenienceInitialisers.swift */; }; @@ -2446,6 +2447,7 @@ D6E83C632B238432006C8AFB /* SettingsAboutView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsAboutView.swift; sourceTree = ""; }; D6E83C652B23936F006C8AFB /* SettingsDebugView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsDebugView.swift; sourceTree = ""; }; D6E83C672B23B6A3006C8AFB /* FontSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FontSettings.swift; sourceTree = ""; }; + D6F93E3B2B4FFA97004C268D /* SubscriptionDebugViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubscriptionDebugViewController.swift; sourceTree = ""; }; EA39B7E1268A1A35000C62CD /* privacy-reference-tests */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; name = "privacy-reference-tests"; path = "submodules/privacy-reference-tests"; sourceTree = SOURCE_ROOT; }; EAB19ED9268963510015D3EA /* DomainMatchingTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DomainMatchingTests.swift; sourceTree = ""; }; EE0153E02A6EABE0002A8B26 /* NetworkProtectionConvenienceInitialisers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkProtectionConvenienceInitialisers.swift; sourceTree = ""; }; @@ -3897,6 +3899,7 @@ isa = PBXGroup; children = ( 858566E7252E4F56007501B8 /* Debug.storyboard */, + D6F93E3B2B4FFA97004C268D /* SubscriptionDebugViewController.swift */, 8590CB602684D0600089F6BF /* CookieDebugViewController.swift */, 4B0295182537BC6700E00CEF /* ConfigurationDebugViewController.swift */, 858566FA252E55D6007501B8 /* ImageCacheDebugViewController.swift */, @@ -6565,6 +6568,7 @@ AA3D854523D9942200788410 /* AppIconSettingsViewController.swift in Sources */, 85C297042476C1FD0063A335 /* DaxDialogsSettings.swift in Sources */, 8505836F219F424500ED4EDB /* UIViewExtension.swift in Sources */, + D6F93E3C2B4FFA97004C268D /* SubscriptionDebugViewController.swift in Sources */, 8505836E219F424500ED4EDB /* RoundedRectangleView.swift in Sources */, D6D12CA12B291CA90054390C /* Logging.swift in Sources */, EE8594992A44791C008A6D06 /* NetworkProtectionTunnelController.swift in Sources */, diff --git a/DuckDuckGo/AppDelegate.swift b/DuckDuckGo/AppDelegate.swift index 5a1e25576c..2040715f88 100644 --- a/DuckDuckGo/AppDelegate.swift +++ b/DuckDuckGo/AppDelegate.swift @@ -355,7 +355,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate { #if SUBSCRIPTION private func setupSubscriptionsEnvironment() { - SubscriptionPurchaseEnvironment.current = .appStore + Task { SubscriptionPurchaseEnvironment.current = .appStore + await AccountManager().checkSubscriptionState() + } } #endif diff --git a/DuckDuckGo/Debug.storyboard b/DuckDuckGo/Debug.storyboard index 5de0d1c5e4..b359cd0648 100644 --- a/DuckDuckGo/Debug.storyboard +++ b/DuckDuckGo/Debug.storyboard @@ -1,9 +1,9 @@ - + - + @@ -222,7 +222,7 @@ - + @@ -242,7 +242,7 @@ - + @@ -251,7 +251,7 @@ - + @@ -260,7 +260,7 @@ - + @@ -269,7 +269,7 @@ - + @@ -278,7 +278,7 @@ - + @@ -287,7 +287,7 @@ - + @@ -879,34 +879,34 @@ - + - + - + - + @@ -945,7 +945,7 @@ - + diff --git a/DuckDuckGo/MainViewController+Segues.swift b/DuckDuckGo/MainViewController+Segues.swift index f523c6763f..7002be5231 100644 --- a/DuckDuckGo/MainViewController+Segues.swift +++ b/DuckDuckGo/MainViewController+Segues.swift @@ -237,7 +237,7 @@ extension MainViewController { appSettings: appSettings, bookmarksDatabase: bookmarksDatabase) - let settingsViewModel = SettingsViewModel(legacyViewProvider: legacyViewProvider) + let settingsViewModel = SettingsViewModel(legacyViewProvider: legacyViewProvider, accountManager: AccountManager()) let settingsController = SettingsHostingController(viewModel: settingsViewModel, viewProvider: legacyViewProvider) settingsController.applyTheme(ThemeManager.shared.currentTheme) @@ -246,6 +246,8 @@ extension MainViewController { navController.applyTheme(ThemeManager.shared.currentTheme) settingsController.modalPresentationStyle = .automatic + settingsController.isModalInPresentation = true + present(navController, animated: true) { completion?(settingsViewModel) } diff --git a/DuckDuckGo/PrivacyPro/Subscription/Flows/AppStore/AppStorePurchaseFlow.swift b/DuckDuckGo/PrivacyPro/Subscription/Flows/AppStore/AppStorePurchaseFlow.swift index 1d4c153f3d..3da3548c24 100644 --- a/DuckDuckGo/PrivacyPro/Subscription/Flows/AppStore/AppStorePurchaseFlow.swift +++ b/DuckDuckGo/PrivacyPro/Subscription/Flows/AppStore/AppStorePurchaseFlow.swift @@ -71,7 +71,7 @@ public final class AppStorePurchaseFlow { accountManager.storeAccount(token: expiredAccountDetails.accessToken, email: expiredAccountDetails.email, externalID: expiredAccountDetails.externalID) - case .missingAccountOrTransactions: + case .missingAccountOrTransactions, .pastTransactionAuthenticationError: // No history, create new account switch await AuthService.createAccount(emailAccessToken: emailAccessToken) { case .success(let response): @@ -104,7 +104,7 @@ public final class AppStorePurchaseFlow { @discardableResult public static func completeSubscriptionPurchase() async -> Result { - let result = await checkForEntitlements(wait: 2.0, retry: 10) + let result = await checkForEntitlements(wait: 2.0, retry: 30) return result ? .success(PurchaseUpdate(type: "completed")) : .failure(.missingEntitlements) } diff --git a/DuckDuckGo/PrivacyPro/Subscription/SubscriptionPurchaseEnvironment.swift b/DuckDuckGo/PrivacyPro/Subscription/SubscriptionPurchaseEnvironment.swift index 8c7d499bfc..0dca730071 100644 --- a/DuckDuckGo/PrivacyPro/Subscription/SubscriptionPurchaseEnvironment.swift +++ b/DuckDuckGo/PrivacyPro/Subscription/SubscriptionPurchaseEnvironment.swift @@ -24,7 +24,7 @@ public final class SubscriptionPurchaseEnvironment { public enum Environment { case appStore, stripe } - + public static var current: Environment = .appStore { didSet { canPurchase = false @@ -39,7 +39,7 @@ public final class SubscriptionPurchaseEnvironment { } public static var canPurchase: Bool = false - + private static func setupForAppStore() { if #available(macOS 12.0, iOS 15.0, *) { Task { diff --git a/DuckDuckGo/PrivacyPro/UserScripts/SubscriptionPagesUseSubscriptionFeature.swift b/DuckDuckGo/PrivacyPro/UserScripts/SubscriptionPagesUseSubscriptionFeature.swift index 6f6187ecac..97ba439af3 100644 --- a/DuckDuckGo/PrivacyPro/UserScripts/SubscriptionPagesUseSubscriptionFeature.swift +++ b/DuckDuckGo/PrivacyPro/UserScripts/SubscriptionPagesUseSubscriptionFeature.swift @@ -23,7 +23,9 @@ import Common import Foundation import WebKit import UserScript +import Combine +@available(iOS 15.0, *) final class SubscriptionPagesUseSubscriptionFeature: Subfeature, ObservableObject { struct Constants { @@ -58,6 +60,8 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature, ObservableObjec } @Published var transactionInProgress = false + @Published var hasActiveSubscription = false + @Published var purchaseError: AppStorePurchaseFlow.Error? var broker: UserScriptMessageBroker? @@ -67,6 +71,8 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature, ObservableObjec .exact(hostname: OriginDomains.duckduckgo), .exact(hostname: OriginDomains.abrown) ]) + + var originalMessage: WKScriptMessage? func with(broker: UserScriptMessageBroker) { self.broker = broker @@ -108,6 +114,11 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature, ObservableObjec } return try await work() } + + private func resetSubscriptionFlow() { + hasActiveSubscription = false + purchaseError = nil + } func getSubscription(params: Any, original: WKScriptMessage) async throws -> Encodable? { let authToken = AccountManager().authToken ?? Constants.empty @@ -117,22 +128,26 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature, ObservableObjec func getSubscriptionOptions(params: Any, original: WKScriptMessage) async throws -> Encodable? { await withTransactionInProgress { - if #available(iOS 15.0, *) { - switch await AppStorePurchaseFlow.subscriptionOptions() { - case .success(let subscriptionOptions): - return subscriptionOptions - case .failure: - // TODO: handle errors - no products found - return nil - } + + resetSubscriptionFlow() + + switch await AppStorePurchaseFlow.subscriptionOptions() { + case .success(let subscriptionOptions): + return subscriptionOptions + case .failure: + // TODO: handle errors - no products found + return nil } - return nil + } } func subscriptionSelected(params: Any, original: WKScriptMessage) async throws -> Encodable? { - + await withTransactionInProgress { + + resetSubscriptionFlow() + struct SubscriptionSelection: Decodable { let id: String } @@ -147,19 +162,9 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature, ObservableObjec print("Selected: \(subscriptionSelection.id)") - // Trigger sign in pop-up - switch await PurchaseManager.shared.syncAppleIDAccount() { - case .success: - break - case .failure: - return nil - } - // Check for active subscriptions if await PurchaseManager.hasActiveSubscription() { - print("hasActiveSubscription: TRUE") - // TODO: Present something here - // await WindowControllersManager.shared.lastKeyMainWindowController?.showSubscriptionFoundAlert(originalMessage: message) + hasActiveSubscription = true return nil } @@ -169,6 +174,8 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature, ObservableObjec case .success: break case .failure: + purchaseError = .purchaseFailed + originalMessage = original return nil } @@ -177,6 +184,7 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature, ObservableObjec await pushPurchaseUpdate(originalMessage: message, purchaseUpdate: purchaseUpdate) case .failure: // TODO: handle errors - missing entitlements on post purchase check + purchaseError = .missingEntitlements await pushPurchaseUpdate(originalMessage: message, purchaseUpdate: PurchaseUpdate(type: "completed")) } } @@ -250,5 +258,18 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature, ObservableObjec print(">>> Pushing into WebView:", method.rawValue, String(describing: params)) broker.push(method: method.rawValue, params: params, for: self, into: webView) } + + func restoreAccountFromAppStorePurchase() async -> Bool { + + await withTransactionInProgress { + switch await AppStoreRestoreFlow.restoreAccountFromPastPurchase() { + case .success(let update): + return true + case .failure: + return false + } + } + + } } #endif diff --git a/DuckDuckGo/PrivacyPro/ViewModel/SubscriptionFlowViewModel.swift b/DuckDuckGo/PrivacyPro/ViewModel/SubscriptionFlowViewModel.swift index a5bf1c4e58..60550e87ea 100644 --- a/DuckDuckGo/PrivacyPro/ViewModel/SubscriptionFlowViewModel.swift +++ b/DuckDuckGo/PrivacyPro/ViewModel/SubscriptionFlowViewModel.swift @@ -20,6 +20,7 @@ import Foundation import UserScript import Combine +import Core #if SUBSCRIPTION @available(iOS 15.0, *) @@ -29,12 +30,16 @@ final class SubscriptionFlowViewModel: ObservableObject { let subFeature: SubscriptionPagesUseSubscriptionFeature let purchaseManager: PurchaseManager - let purchaseURL = URL.purchaseSubscription let viewTitle = UserText.settingsPProSection - - @Published var transactionInProgress = false + private var cancellables = Set() - + + // State variables + var purchaseURL = URL.purchaseSubscription + @Published var hasActiveSubscription = false + @Published var transactionInProgress = false + @Published var shouldReloadWebview = false + init(userScript: SubscriptionPagesUserScript = SubscriptionPagesUserScript(), subFeature: SubscriptionPagesUseSubscriptionFeature = SubscriptionPagesUseSubscriptionFeature(), purchaseManager: PurchaseManager = PurchaseManager.shared) { @@ -45,12 +50,20 @@ final class SubscriptionFlowViewModel: ObservableObject { // Observe transaction status private func setupTransactionObserver() async { + subFeature.$transactionInProgress .sink { [weak self] status in guard let self = self else { return } Task { await self.setTransactionInProgress(status) } } .store(in: &cancellables) + + subFeature.$hasActiveSubscription + .receive(on: DispatchQueue.main) + .sink { [weak self] value in + self?.hasActiveSubscription = value + } + .store(in: &cancellables) } @MainActor @@ -62,5 +75,17 @@ final class SubscriptionFlowViewModel: ObservableObject { await self.setupTransactionObserver() } + func restoreAppstoreTransaction() { + Task { + if await subFeature.restoreAccountFromAppStorePurchase() { + await MainActor.run { shouldReloadWebview = true } + } else { + await MainActor.run { + // TODO: Display error when restoring subscription + } + } + } + } + } #endif diff --git a/DuckDuckGo/PrivacyPro/Views/HeadlessWebView.swift b/DuckDuckGo/PrivacyPro/Views/HeadlessWebView.swift index 164a19014f..44fd4c026b 100644 --- a/DuckDuckGo/PrivacyPro/Views/HeadlessWebView.swift +++ b/DuckDuckGo/PrivacyPro/Views/HeadlessWebView.swift @@ -27,24 +27,23 @@ import Core struct HeadlessWebview: UIViewRepresentable { let userScript: UserScriptMessaging let subFeature: Subfeature - let url: URL + @Binding var url: URL + @Binding var shouldReload: Bool func makeUIView(context: Context) -> WKWebView { - let userContentController = WKUserContentController() - userContentController.addUserScript(userScript.makeWKUserScriptSync()) - userContentController.addHandler(userScript) - userScript.registerSubfeature(delegate: subFeature) - let configuration = WKWebViewConfiguration() - configuration.userContentController = userContentController + configuration.userContentController = makeUserContentController() let webView = WKWebView(frame: .zero, configuration: configuration) // We're using the macOS agent as the config for iOS has not been deployed in test env webView.customUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko)" // DefaultUserAgentManager.shared.update(webView: webView, isDesktop: false, url: url) - - webView.load(URLRequest(url: url)) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0) { + webView.load(URLRequest(url: url)) + } + #if DEBUG if #available(iOS 16.4, *) { @@ -53,21 +52,46 @@ struct HeadlessWebview: UIViewRepresentable { #endif return webView } + + func updateUIView(_ uiView: WKWebView, context: Context) { + if shouldReload { + uiView.reload() + shouldReload = false + } + } - func updateUIView(_ uiView: WKWebView, context: Context) {} + func makeCoordinator() -> Coordinator { + Coordinator() + } + + @MainActor + private func makeUserContentController() -> WKUserContentController { + let userContentController = WKUserContentController() + userContentController.addUserScript(userScript.makeWKUserScriptSync()) + userContentController.addHandler(userScript) + userScript.registerSubfeature(delegate: subFeature) + return userContentController + } + + class Coordinator: NSObject { + var webView: WKWebView? + } } struct AsyncHeadlessWebView: View { - let url: URL + @Binding var url: URL let userScript: UserScriptMessaging let subFeature: Subfeature + @Binding var shouldReload: Bool var body: some View { GeometryReader { geometry in HeadlessWebview(userScript: userScript, subFeature: subFeature, - url: url) + url: $url, + shouldReload: $shouldReload) .frame(width: geometry.size.width, height: geometry.size.height) } } + } diff --git a/DuckDuckGo/PrivacyPro/Views/SubscriptionFlowView.swift b/DuckDuckGo/PrivacyPro/Views/SubscriptionFlowView.swift index f1fbbe38a5..4060d53e57 100644 --- a/DuckDuckGo/PrivacyPro/Views/SubscriptionFlowView.swift +++ b/DuckDuckGo/PrivacyPro/Views/SubscriptionFlowView.swift @@ -28,19 +28,41 @@ struct SubscriptionFlowView: View { var body: some View { ZStack { - AsyncHeadlessWebView(url: viewModel.purchaseURL, + AsyncHeadlessWebView(url: $viewModel.purchaseURL, userScript: viewModel.userScript, - subFeature: viewModel.subFeature).background() + subFeature: viewModel.subFeature, + shouldReload: $viewModel.shouldReloadWebview).background() // Overlay that appears when transaction is in progress if viewModel.transactionInProgress { PurchaseInProgressView() } } + .onChange(of: viewModel.shouldReloadWebview) { shouldReload in + if shouldReload { + print("WebView reload triggered") + viewModel.shouldReloadWebview = false + } + } .onAppear(perform: { Task { await viewModel.initializeViewData() } }) .navigationTitle(viewModel.viewTitle) + .navigationBarBackButtonHidden(viewModel.transactionInProgress) + + // Active subscription found Alert + .alert(isPresented: $viewModel.hasActiveSubscription) { + Alert( + title: Text("Subscription Found"), + message: Text("We found a subscription associated with this Apple ID."), + primaryButton: .cancel(Text("Cancel")) { + // TODO: Handle subscription Restore cancellation + }, + secondaryButton: .default(Text("Restore")) { + viewModel.restoreAppstoreTransaction() + } + ) + } } } #endif diff --git a/DuckDuckGo/SettingsCell.swift b/DuckDuckGo/SettingsCell.swift index 76a0e469dd..2549e01d67 100644 --- a/DuckDuckGo/SettingsCell.swift +++ b/DuckDuckGo/SettingsCell.swift @@ -89,20 +89,25 @@ struct SettingsCellView: View, Identifiable { } var body: some View { + if asLink { + Button(action: action) { + cellContent + .disabled(!enabled) + } + .buttonStyle(PlainButtonStyle()) + .contentShape(Rectangle()) + } else { + cellContent + } + } + + private var cellContent: some View { Group { switch accesory { case .custom(let customView): - Button(action: action) { customView } - .buttonStyle(.plain) - .disabled(!enabled) - + customView default: - if asLink { - Button(action: action) { defaultView } - .buttonStyle(.plain) - } else { - defaultView - } + defaultView } } } @@ -234,6 +239,19 @@ struct SettingsCustomCell: View { } var body: some View { + if asLink { + Button(action: action) { + cellContent + } + .buttonStyle(PlainButtonStyle()) + .contentShape(Rectangle()) + } else { + cellContent + } + } + + + private var cellContent: some View { HStack { content Spacer() @@ -241,7 +259,6 @@ struct SettingsCustomCell: View { SettingsCellComponents.chevron } } - .onTapGesture(perform: action) } } diff --git a/DuckDuckGo/SettingsPrivacyProView.swift b/DuckDuckGo/SettingsPrivacyProView.swift index 31c7bd0f45..2c439d92a9 100644 --- a/DuckDuckGo/SettingsPrivacyProView.swift +++ b/DuckDuckGo/SettingsPrivacyProView.swift @@ -42,6 +42,13 @@ struct SettingsPrivacyProView: View { .foregroundColor(Color.init(designSystemColor: .accent)) } + private var manageSubscriptionView: some View { + Text(UserText.settingsPProManageSubscription) + .daxBodyRegular() + .foregroundColor(Color.init(designSystemColor: .accent)) + } + + private var purchaseSubscriptionView: some View { return Group { SettingsCustomCell(content: { privacyProDescriptionView }) @@ -51,12 +58,29 @@ struct SettingsPrivacyProView: View { } } + private var subscriptionDetailsView: some View { + return Group { + SettingsCellView(label: UserText.settingsPProVPNTitle, + subtitle: viewModel.state.networkProtection.status != "" ? viewModel.state.networkProtection.status : nil, + action: { viewModel.presentLegacyView(.netP) }, + asLink: true, + disclosureIndicator: true) + + SettingsCellView(label: UserText.settingsPProDBPTitle, subtitle: UserText.settingsPProDBPSubTitle) + SettingsCellView(label: UserText.settingsPProITRTitle, subtitle: UserText.settingsPProITRSubTitle) + + NavigationLink(destination: SubscriptionFlowView(viewModel: SubscriptionFlowViewModel())) { + SettingsCustomCell(content: { manageSubscriptionView }) + } + } + } + var body: some View { if viewModel.state.privacyPro.enabled { Section(header: Text(UserText.settingsPProSection)) { if viewModel.state.privacyPro.hasActiveSubscription { - + subscriptionDetailsView } else { purchaseSubscriptionView } diff --git a/DuckDuckGo/SettingsState.swift b/DuckDuckGo/SettingsState.swift index ad56cbf297..95fd708149 100644 --- a/DuckDuckGo/SettingsState.swift +++ b/DuckDuckGo/SettingsState.swift @@ -21,6 +21,10 @@ import BrowserServicesKit struct SettingsState { + enum PrivacyProSubscriptionStatus { + case active, inactive, unknown + } + struct AddressBar { var enabled: Bool var position: AddressBarPosition @@ -38,10 +42,15 @@ struct SettingsState { struct PrivacyPro { var enabled: Bool - var canPurchaseSubscription: Bool + var canPurchase: Bool var hasActiveSubscription: Bool } + struct SyncSettings { + var enabled: Bool + var title: String + } + // Appearance properties var appTheme: ThemeName var appIcon: AppIcon @@ -68,7 +77,6 @@ struct SettingsState { // Features var debugModeEnabled: Bool - var syncEnabled: Bool var voiceSearchEnabled: Bool var speechRecognitionEnabled: Bool var loginsEnabled: Bool @@ -78,6 +86,9 @@ struct SettingsState { // Subscriptions Properties var privacyPro: PrivacyPro + + // Sync Propertiers + var sync: SyncSettings static var defaults: SettingsState { return SettingsState( @@ -96,12 +107,13 @@ struct SettingsState { activeWebsiteAccount: nil, version: "0.0.0.0", debugModeEnabled: false, - syncEnabled: false, voiceSearchEnabled: false, speechRecognitionEnabled: false, loginsEnabled: false, networkProtection: NetworkProtection(enabled: false, status: ""), - privacyPro: PrivacyPro(enabled: false, canPurchaseSubscription: false, hasActiveSubscription: false) + privacyPro: PrivacyPro(enabled: false, canPurchase: false, + hasActiveSubscription: false), + sync: SyncSettings(enabled: false, title: "") ) } } diff --git a/DuckDuckGo/SettingsSyncView.swift b/DuckDuckGo/SettingsSyncView.swift index a3ba91bc68..bda8d96bcd 100644 --- a/DuckDuckGo/SettingsSyncView.swift +++ b/DuckDuckGo/SettingsSyncView.swift @@ -32,7 +32,7 @@ struct SettingsSyncView: View { var body: some View { - if viewModel.state.syncEnabled { + if viewModel.state.sync.enabled { Section { SettingsCellView(label: SyncUI.UserText.syncTitle, action: { viewModel.presentLegacyView(.sync) }, diff --git a/DuckDuckGo/SettingsViewModel.swift b/DuckDuckGo/SettingsViewModel.swift index 21b7593f80..41a4b964ec 100644 --- a/DuckDuckGo/SettingsViewModel.swift +++ b/DuckDuckGo/SettingsViewModel.swift @@ -23,6 +23,7 @@ import Persistence import SwiftUI import Common import Combine +import SyncUI #if APP_TRACKING_PROTECTION import NetworkExtension @@ -41,6 +42,7 @@ final class SettingsViewModel: ObservableObject { private lazy var animator: FireButtonAnimator = FireButtonAnimator(appSettings: AppUserDefaults()) private var legacyViewProvider: SettingsLegacyViewProvider private lazy var versionProvider: AppVersion = AppVersion.shared + private var accountManager: AccountManager #if NETWORK_PROTECTION private let connectionObserver = ConnectionStatusObserverThroughSession() @@ -50,12 +52,18 @@ final class SettingsViewModel: ObservableObject { private lazy var isPad = UIDevice.current.userInterfaceIdiom == .pad private var cancellables = Set() + // Defaults + @UserDefaultsWrapper(key: .privacyProHasActiveSubscription, defaultValue: false) + static private var cachedHasActiveSubscription: Bool + // Closures to interact with legacy view controllers throught the container var onRequestPushLegacyView: ((UIViewController) -> Void)? var onRequestPresentLegacyView: ((UIViewController, _ modal: Bool) -> Void)? var onRequestPopLegacyView: (() -> Void)? var onRequestDismissSettings: (() -> Void)? + static let entitlementNames = ["dummy1", "dummy2", "dummy3"] + // Our View State @Published private(set) var state: SettingsState @@ -74,19 +82,6 @@ final class SettingsViewModel: ObservableObject { var shouldShowNoMicrophonePermissionAlert: Bool = false - var shouldShowNetworkProtectionCell: Bool { -#if NETWORK_PROTECTION - if #available(iOS 15, *) { - let accessController = NetworkProtectionAccessController() - return accessController.networkProtectionAccessType() != .none - } else { - return false - } -#else - return false -#endif - } - // MARK: Bindings var themeBinding: Binding { Binding( @@ -188,9 +183,10 @@ final class SettingsViewModel: ObservableObject { } // MARK: Default Init - init(state: SettingsState? = nil, legacyViewProvider: SettingsLegacyViewProvider) { + init(state: SettingsState? = nil, legacyViewProvider: SettingsLegacyViewProvider, accountManager: AccountManager) { self.state = SettingsState.defaults self.legacyViewProvider = legacyViewProvider + self.accountManager = accountManager } } @@ -217,40 +213,64 @@ extension SettingsViewModel { activeWebsiteAccount: nil, version: versionProvider.versionAndBuildNumber, debugModeEnabled: featureFlagger.isFeatureOn(.debugMenu) || isDebugBuild, - syncEnabled: featureFlagger.isFeatureOn(.sync), voiceSearchEnabled: AppDependencyProvider.shared.voiceSearchHelper.isSpeechRecognizerAvailable, speechRecognitionEnabled: AppDependencyProvider.shared.voiceSearchHelper.isSpeechRecognizerAvailable, loginsEnabled: featureFlagger.isFeatureOn(.autofillAccessCredentialManagement), - networkProtection: { - var enabled = false -#if NETWORK_PROTECTION - if #available(iOS 15, *) { - let accessController = NetworkProtectionAccessController() - enabled = accessController.networkProtectionAccessType() != .none - } -#endif - return SettingsState.NetworkProtection(enabled: enabled, status: "") - }(), - privacyPro: { - var enabled = false - var canPurchaseSubscription = false - var hasActiveSubscription = false -#if SUBSCRIPTION - enabled = featureFlagger.isFeatureOn(.privacyPro) - canPurchaseSubscription = SubscriptionPurchaseEnvironment.canPurchase - hasActiveSubscription = false -#endif - return SettingsState.PrivacyPro(enabled: enabled, - canPurchaseSubscription: canPurchaseSubscription, - hasActiveSubscription: hasActiveSubscription) - }() + networkProtection: getNetworkProtectionState(), + privacyPro: getPrivacyProState(), + sync: getSyncState() ) + setupSubscribers() -#if SUBSCRIPTION + + #if SUBSCRIPTION if #available(iOS 15, *) { - Task { await setupSubscriptionEnvironment() } + Task { + if state.privacyPro.enabled { + await setupSubscriptionEnvironment() + } + } } -#endif + #endif + } + + private func getNetworkProtectionState() -> SettingsState.NetworkProtection { + var enabled = false + #if NETWORK_PROTECTION + if #available(iOS 15, *) { + let accessController = NetworkProtectionAccessController() + enabled = accessController.networkProtectionAccessType() != .none + } + #endif + return SettingsState.NetworkProtection(enabled: enabled, status: "") + } + + private func getPrivacyProState() -> SettingsState.PrivacyPro { + var enabled = false + var canPurchase = false + var hasActiveSubscription = Self.cachedHasActiveSubscription + #if SUBSCRIPTION + enabled = featureFlagger.isFeatureOn(.privacyPro) + canPurchase = SubscriptionPurchaseEnvironment.canPurchase + #endif + return SettingsState.PrivacyPro(enabled: enabled, + canPurchase: canPurchase, + hasActiveSubscription: hasActiveSubscription) + } + + private func getSyncState() -> SettingsState.SyncSettings { + SettingsState.SyncSettings(enabled: legacyViewProvider.syncService.featureFlags.contains(.userInterface), + title: { + let syncService = legacyViewProvider.syncService + let isDataSyncingDisabled = !syncService.featureFlags.contains(.dataSyncing) + && syncService.authState == .active + if SyncBookmarksAdapter.isSyncBookmarksPaused + || SyncCredentialsAdapter.isSyncCredentialsPaused + || isDataSyncingDisabled { + return "⚠️ \(UserText.settingsSync)" + } + return SyncUI.UserText.syncTitle + }()) } private func firePixel(_ event: Pixel.Event) { @@ -267,22 +287,53 @@ extension SettingsViewModel { completion(true) } } + -#if SUBSCRIPTION - @available(iOS 15.0, *) - private func setupSubscriptionEnvironment() async { - await PurchaseManager.shared.updateAvailableProducts() - PurchaseManager.shared.$availableProducts - .receive(on: RunLoop.main) - .sink { [weak self] products in - self?.state.privacyPro.enabled = !products.isEmpty - self?.state.privacyPro.canPurchaseSubscription = !products.isEmpty - }.store(in: &cancellables) - + #if SUBSCRIPTION + @available(iOS 15.0, *) + @MainActor + private func setupSubscriptionEnvironment() async { + + // Active subscription check + if let token = accountManager.accessToken { + + // Fetch available subscriptions from the backend (or sign out) + if case .success(let response) = await SubscriptionService.getSubscriptionDetails(token: token) { + if !response.isSubscriptionActive { + AccountManager().signOut() + setupSubscriptionPurchaseOptions() + return + } + + // Check for valid entitlements + let hasEntitlements = await AccountManager().hasEntitlement(for: Self.entitlementNames.first!) + self.state.privacyPro.hasActiveSubscription = hasEntitlements ? true : false + + // Cache Subscription state + Self.cachedHasActiveSubscription = self.state.privacyPro.hasActiveSubscription + + // Enable Subscription purchase if there's no active subscription + if self.state.privacyPro.hasActiveSubscription == false { + setupSubscriptionPurchaseOptions() + } + } + } else { + setupSubscriptionPurchaseOptions() + } } -#endif -#if NETWORK_PROTECTION + @available(iOS 15.0, *) + private func setupSubscriptionPurchaseOptions() { + self.state.privacyPro.hasActiveSubscription = false + PurchaseManager.shared.$availableProducts + .receive(on: RunLoop.main) + .sink { [weak self] products in + self?.state.privacyPro.canPurchase = !products.isEmpty + }.store(in: &cancellables) + } + #endif + + #if NETWORK_PROTECTION private func updateNetPStatus(connectionStatus: ConnectionStatus) { switch NetworkProtectionAccessController().networkProtectionAccessType() { case .none, .waitlistAvailable, .waitlistJoined, .waitlistInvitedPendingTermsAcceptance: @@ -296,7 +347,8 @@ extension SettingsViewModel { } } } -#endif + #endif + } // MARK: Subscribers @@ -305,14 +357,14 @@ extension SettingsViewModel { private func setupSubscribers() { -#if NETWORK_PROTECTION + #if NETWORK_PROTECTION connectionObserver.publisher .receive(on: DispatchQueue.main) - .sink { [weak self] status in - self?.updateNetPStatus(connectionStatus: status) + .sink { [weak self] hasActiveSubscription in + self?.updateNetPStatus(connectionStatus: hasActiveSubscription) } .store(in: &cancellables) -#endif + #endif } } diff --git a/DuckDuckGo/SubscriptionDebugViewController.swift b/DuckDuckGo/SubscriptionDebugViewController.swift index 77454c95c6..d688ebf0e1 100644 --- a/DuckDuckGo/SubscriptionDebugViewController.swift +++ b/DuckDuckGo/SubscriptionDebugViewController.swift @@ -27,27 +27,40 @@ final class SubscriptionDebugViewController: UITableViewController { #else +@available(iOS 15.0, *) final class SubscriptionDebugViewController: UITableViewController { private let accountManager = AccountManager() - - @available(macOS 12.0, iOS 15.0, *) fileprivate var purchaseManager: PurchaseManager = PurchaseManager.shared private let titles = [ Sections.authorization: "Authentication", + Sections.subscription: "Subscription", + Sections.appstore: "App Store", ] enum Sections: Int, CaseIterable { case authorization + case subscription + case appstore } enum AuthorizationRows: Int, CaseIterable { - case showDetails + case showAccountDetails case clearAuthData case injectCredentials - } + + enum SubscriptionRows: Int, CaseIterable { + case validateToken + case getEntitlements + case getSubscription + } + + enum AppStoreRows: Int, CaseIterable { + case syncAppStoreAccount + } + override func numberOfSections(in tableView: UITableView) -> Int { return Sections.allCases.count @@ -58,6 +71,7 @@ final class SubscriptionDebugViewController: UITableViewController { return titles[section] } + // swiftlint:disable cyclomatic_complexity override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) @@ -69,7 +83,7 @@ final class SubscriptionDebugViewController: UITableViewController { switch AuthorizationRows(rawValue: indexPath.row) { case .clearAuthData: cell.textLabel?.text = "Clear Authorization Data (Sign out)" - case .showDetails: + case .showAccountDetails: cell.textLabel?.text = "Show Account Details" case .injectCredentials: cell.textLabel?.text = "Simulate Authentication (Inject Fake token)" @@ -79,42 +93,76 @@ final class SubscriptionDebugViewController: UITableViewController { case.none: break + + case .appstore: + switch AppStoreRows(rawValue: indexPath.row) { + case .syncAppStoreAccount: + cell.textLabel?.text = "Sync App Store Account" + case .none: + break + } + + case .subscription: + switch SubscriptionRows(rawValue: indexPath.row) { + case .validateToken: + cell.textLabel?.text = "Validate Token" + case .getSubscription: + cell.textLabel?.text = "Get subscription details" + case .getEntitlements: + cell.textLabel?.text = "Get Entitlements" + case .none: + break + } } - return cell } + // swiftlint:enable cyclomatic_complexity override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { switch Sections(rawValue: section) { case .authorization: return AuthorizationRows.allCases.count + case .subscription: return SubscriptionRows.allCases.count + case .appstore: return AppStoreRows.allCases.count case .none: return 0 } } + // swiftlint:disable cyclomatic_complexity override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { switch Sections(rawValue: indexPath.section) { case .authorization: switch AuthorizationRows(rawValue: indexPath.row) { case .clearAuthData: clearAuthData() - case .showDetails: showDetails() + case .showAccountDetails: showAccountDetails() case .injectCredentials: injectCredentials() default: break } + case .appstore: + switch AppStoreRows(rawValue: indexPath.row) { + case .syncAppStoreAccount: syncAppleIDAccount() + default: break + } + case .subscription: + switch SubscriptionRows(rawValue: indexPath.row) { + case .validateToken: validateToken() + case .getSubscription: getSubscription() + case .getEntitlements: getEntitlements() + default: break + } case .none: break } tableView.deselectRow(at: indexPath, animated: true) } + // swiftlint:enable cyclomatic_complexity private func showAlert(title: String, message: String? = nil) { DispatchQueue.main.async { let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) let okAction = UIAlertAction(title: "OK", style: .default, handler: nil) alertController.addAction(okAction) - - // Assuming this function is in a UIViewController subclass self.present(alertController, animated: true, completion: nil) } } @@ -122,21 +170,81 @@ final class SubscriptionDebugViewController: UITableViewController { // MARK: Account Status Actions private func clearAuthData() { accountManager.signOut() + showAlert(title: "Data cleared!") } private func injectCredentials() { accountManager.storeAccount(token: "a-fake-token", email: "a.fake@email.com", externalID: "666") + showAccountDetails() } - private func showDetails() { + private func showAccountDetails() { let title = accountManager.isUserAuthenticated ? "Authenticated" : "Not Authenticated" let message = accountManager.isUserAuthenticated ? ["AuthToken: \(accountManager.authToken ?? "")", "AccessToken: \(accountManager.accessToken ?? "")", "Email: \(accountManager.email ?? "")"].joined(separator: "\n") : nil showAlert(title: title, message: message) } + + private func syncAppleIDAccount() { + Task { + switch await purchaseManager.syncAppleIDAccount() { + case .success: + showAlert(title: "Account synced!", message: "") + case .failure(let error): + showAlert(title: "Error syncing!", message: error.localizedDescription) + } + } + } + + private func validateToken() { + Task { + guard let token = accountManager.accessToken else { + showAlert(title: "Not authenticated", message: "No authenticated user found! - Token not available") + return + } + switch await AuthService.validateToken(accessToken: token) { + case .success(let response): + showAlert(title: "Token details", message: "\(response)") + case .failure(let error): + showAlert(title: "Error Validating Token", message: "\(error)") + } + } + } + + private func getSubscription() { + Task { + guard let token = accountManager.accessToken else { + showAlert(title: "Not authenticated", message: "No authenticated user found! - Subscription not available") + return + } + switch await SubscriptionService.getSubscriptionDetails(token: token) { + case .success(let response): + showAlert(title: "Subscription info", message: "\(response)") + case .failure(let error): + showAlert(title: "Subscription Error", message: "\(error)") + } + } + } + + private func getEntitlements() { + Task { + var results: [String] = [] + guard let token = accountManager.accessToken else { + showAlert(title: "Not authenticated", message: "No authenticated user found! - Subscription not available") + return + } + for entitlementName in ["fake", "dummy1", "dummy2", "dummy3"] { + let result = await AccountManager().hasEntitlement(for: entitlementName) + let resultSummary = "Entitlement check for \(entitlementName): \(result)" + results.append(resultSummary) + print(resultSummary) + } + showAlert(title: "Available Entitlements", message: results.joined(separator: "\n")) + } + } } #endif diff --git a/DuckDuckGo/UserText.swift b/DuckDuckGo/UserText.swift index 3bc8394e3a..cf49931d46 100644 --- a/DuckDuckGo/UserText.swift +++ b/DuckDuckGo/UserText.swift @@ -972,6 +972,16 @@ But if you *do* want a peek under the hood, you can find more information about public static let settingsPProLearnMore = NSLocalizedString("settings.ppro.learn.more", value: "Learn More", comment: "Learn more button text for privacy pro") + public static let settingsPProManageSubscription = NSLocalizedString("settings.ppro.manage", value: "Subscription Settings", comment: "Subscription Settings button text for privacy pro") + + public static let settingsPProVPNTitle = NSLocalizedString("settings.ppro.VPN.title", value: "VPN", comment: "VPN cell title for privacy pro") + public static let settingsPProDBPTitle = NSLocalizedString("settings.ppro.DBP.title", value: "Personal Information Removal", comment: "Data Broker protection cell title for privacy pro") + public static let settingsPProDBPSubTitle = NSLocalizedString("settings.ppro.DBP.subtitle", value: "Remove your info from sites that sell it", comment: "Data Broker protection cell subtitle for privacy pro") + public static let settingsPProITRTitle = NSLocalizedString("settings.ppro.ITR.title", value: "Identity Theft Restoration", comment: "Identity theft restoration cell title for privacy pro") + public static let settingsPProITRSubTitle = NSLocalizedString("settings.ppro.ITR.subtitle", value: "If your identity is stolen, we'll help restore it", comment: "Identity theft restoration cell subtitle for privacy pro") + + + // Customize Section public static let settingsCustomizeSection = NSLocalizedString("settings.customize", value: "Customize", comment: "Settings title for the customize section") public static let settingsKeyboard = NSLocalizedString("settings.keyboard", value: "Keyboard", comment: "Settings screen cell for Keyboard") @@ -992,5 +1002,6 @@ But if you *do* want a peek under the hood, you can find more information about public static let settingsVersion = NSLocalizedString("settings.version", value: "Version", comment: "Settings cell for Version") public static let settingsFeedback = NSLocalizedString("settings.feedback", value: "Share Feedback", comment: "Settings cell for Feedback") + } diff --git a/DuckDuckGo/en.lproj/Localizable.strings b/DuckDuckGo/en.lproj/Localizable.strings index 75d92578ff..689f758091 100644 --- a/DuckDuckGo/en.lproj/Localizable.strings +++ b/DuckDuckGo/en.lproj/Localizable.strings @@ -1837,6 +1837,12 @@ But if you *do* want a peek under the hood, you can find more information about /* Product name for the subscription bundle */ "settings.ppro" = "Privacy Pro"; +/* Data Broker protection cell subtitle for privacy pro */ +"settings.ppro.DBP.subtitle" = "Remove your info from sites that sell it"; + +/* Data Broker protection cell title for privacy pro */ +"settings.ppro.DBP.title" = "Personal Information Removal"; + /* Privacy pro description subtext */ "settings.ppro.description" = "More seamless privacy with three new protections, including:"; @@ -1845,12 +1851,24 @@ But if you *do* want a peek under the hood, you can find more information about • Personal Information Removal • Identity Theft Restoration"; +/* Identity theft restoration cell subtitle for privacy pro */ +"settings.ppro.ITR.subtitle" = "If your identity is stolen, we'll help restore it"; + +/* Identity theft restoration cell title for privacy pro */ +"settings.ppro.ITR.title" = "Identity Theft Restioration"; + /* Learn more button text for privacy pro */ "settings.ppro.learn.more" = "Learn More"; +/* Subscription Settings button text for privacy pro */ +"settings.ppro.manage" = "Subscription Settings"; + /* Call to action title for Privacy Pro */ "settings.ppro.subscribe" = "Subscribe to Privacy Pro"; +/* VPN cell title for privacy pro */ +"settings.ppro.VPN.title" = "VPN (Virtual Private Network"; + /* Settings screen cell for long press previews */ "settings.previews" = "Long-Press Previews";