diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index e75d3801d2..3c12c96c14 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -787,6 +787,7 @@ CBDD5DE129A6741300832877 /* MockBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBDD5DE029A6741300832877 /* MockBundle.swift */; }; CBEFB9142AE0844700DEDE7B /* CriticalAlerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBEFB9102ADFFE7900DEDE7B /* CriticalAlerts.swift */; }; CBFCB30E2B2CD47800253E9E /* ConfigurationURLDebugViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBFCB30D2B2CD47800253E9E /* ConfigurationURLDebugViewController.swift */; }; + 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 */; }; D63657192A7BAE7C001AF19D /* EmailManagerRequestDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63657182A7BAE7C001AF19D /* EmailManagerRequestDelegate.swift */; }; @@ -2455,6 +2456,7 @@ CBF14FC427970AB0001D94D0 /* HomeMessageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeMessageViewModel.swift; sourceTree = ""; }; CBF14FC627970C8A001D94D0 /* HomeMessageCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeMessageCollectionViewCell.swift; sourceTree = ""; }; CBFCB30D2B2CD47800253E9E /* ConfigurationURLDebugViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationURLDebugViewController.swift; sourceTree = ""; }; + D60B1F262B9DDE5A00AE4760 /* SubscriptionGoogleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionGoogleView.swift; sourceTree = ""; }; D63657182A7BAE7C001AF19D /* EmailManagerRequestDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EmailManagerRequestDelegate.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 = ""; }; @@ -4641,6 +4643,7 @@ D6BFCB5E2B7524AA0051FF81 /* SubscriptionPIRView.swift */, D6F93E3D2B50A8A0004C268D /* SubscriptionSettingsView.swift */, D6D95CE02B6D52DA00960317 /* RootPresentationMode.swift */, + D60B1F262B9DDE5A00AE4760 /* SubscriptionGoogleView.swift */, ); path = Views; sourceTree = ""; @@ -6891,6 +6894,7 @@ 1E908BF129827C480008C8F3 /* AutoconsentUserScript.swift in Sources */, 4B0295192537BC6700E00CEF /* ConfigurationDebugViewController.swift in Sources */, 1E7A71192934EC6100B7EA19 /* OmniBarNotificationContainerView.swift in Sources */, + D60B1F272B9DDE5A00AE4760 /* SubscriptionGoogleView.swift in Sources */, 984D035C24AE15CD0066CFB8 /* TabSwitcherSettings.swift in Sources */, D6E83C562B21ECC1006C8AFB /* SettingsLegacyViewProvider.swift in Sources */, 98B31292218CCB8C00E54DE1 /* AppDependencyProvider.swift in Sources */, diff --git a/DuckDuckGo/Subscription/AsyncHeadlessWebview/AsyncHeadlessWebViewModel.swift b/DuckDuckGo/Subscription/AsyncHeadlessWebview/AsyncHeadlessWebViewModel.swift index 688f0ed213..02e09c59c6 100644 --- a/DuckDuckGo/Subscription/AsyncHeadlessWebview/AsyncHeadlessWebViewModel.swift +++ b/DuckDuckGo/Subscription/AsyncHeadlessWebview/AsyncHeadlessWebViewModel.swift @@ -62,7 +62,9 @@ final class AsyncHeadlessWebViewViewModel: ObservableObject { initialScrollPositionSubject.send(newPosition) isFirstUpdate = false } else { - subsequentScrollPositionSubject.send(newPosition) + DispatchQueue.main.async { + self.subsequentScrollPositionSubject.send(newPosition) + } } } diff --git a/DuckDuckGo/Subscription/Subscription.xcassets/google-play.imageset/Contents.json b/DuckDuckGo/Subscription/Subscription.xcassets/google-play.imageset/Contents.json new file mode 100644 index 0000000000..f9dd463792 --- /dev/null +++ b/DuckDuckGo/Subscription/Subscription.xcassets/google-play.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "google-play.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DuckDuckGo/Subscription/Subscription.xcassets/google-play.imageset/google-play.svg b/DuckDuckGo/Subscription/Subscription.xcassets/google-play.imageset/google-play.svg new file mode 100644 index 0000000000..c02939096d --- /dev/null +++ b/DuckDuckGo/Subscription/Subscription.xcassets/google-play.imageset/google-play.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/DuckDuckGo/Subscription/ViewModel/SubscriptionFlowViewModel.swift b/DuckDuckGo/Subscription/ViewModel/SubscriptionFlowViewModel.swift index 295b22e45b..d0fadf911b 100644 --- a/DuckDuckGo/Subscription/ViewModel/SubscriptionFlowViewModel.swift +++ b/DuckDuckGo/Subscription/ViewModel/SubscriptionFlowViewModel.swift @@ -239,9 +239,7 @@ final class SubscriptionFlowViewModel: ObservableObject { .receive(on: DispatchQueue.main) .sink { [weak self] value in guard let strongSelf = self else { return } - - let shouldNavigateBack = value && (strongSelf.webViewModel.url?.lastPathComponent != URL.subscriptionBaseURL.lastPathComponent) - strongSelf.canNavigateBack = shouldNavigateBack + strongSelf.canNavigateBack = value } } diff --git a/DuckDuckGo/Subscription/ViewModel/SubscriptionSettingsViewModel.swift b/DuckDuckGo/Subscription/ViewModel/SubscriptionSettingsViewModel.swift index 102a365523..73fe6f48a8 100644 --- a/DuckDuckGo/Subscription/ViewModel/SubscriptionSettingsViewModel.swift +++ b/DuckDuckGo/Subscription/ViewModel/SubscriptionSettingsViewModel.swift @@ -27,20 +27,22 @@ import Core @available(iOS 15.0, *) final class SubscriptionSettingsViewModel: ObservableObject { - enum Constants { - static let monthlyProductID = "ios.subscription.1month" - static let yearlyProductID = "ios.subscription.1year" - static let updateFrequency: Float = 10 - } - let accountManager: AccountManager private var subscriptionUpdateTimer: Timer? private var signOutObserver: Any? + private var subscriptionInfo: SubscriptionService.GetSubscriptionResponse? @Published var subscriptionDetails: String = "" @Published var subscriptionType: String = "" @Published var shouldDisplayRemovalNotice: Bool = false @Published var shouldDismissView: Bool = false + @Published var shouldDisplayGoogleView: Bool = false + + // Used to display stripe WebUI + @Published var stripeViewModel: SubscriptionExternalLinkViewModel? + @Published var shouldDisplayStripeView: Bool = false + private var externalAllowedDomains = ["stripe.com"] + init(accountManager: AccountManager = AccountManager()) { self.accountManager = accountManager @@ -62,14 +64,33 @@ final class SubscriptionSettingsViewModel: ObservableObject { let subscriptionResult = await SubscriptionService.getSubscription(accessToken: token, cachePolicy: cachePolicy) switch subscriptionResult { case .success(let subscription): - updateSubscriptionDetails(status: subscription.status, date: subscription.expiresOrRenewsAt, product: subscription.productId) - case .failure(let error): + subscriptionInfo = subscription + updateSubscriptionsStatusMessage(status: subscription.status, + date: subscription.expiresOrRenewsAt, + product: subscription.productId, + billingPeriod: subscription.billingPeriod) + case .failure: AccountManager().signOut() shouldDismissView = true } } } + func manageSubscription() { + switch subscriptionInfo?.platform { + case .apple: + Task { await manageAppleSubscription() } + case .google: + manageGoogleSubscription() + case .stripe: + Task { await manageStripeSubscription() } + default: + return + } + } + + // MARK: - + private func setupNotificationObservers() { signOutObserver = NotificationCenter.default.addObserver(forName: .accountDidSignOut, object: nil, queue: .main) { [weak self] _ in DispatchQueue.main.async { @@ -88,12 +109,11 @@ final class SubscriptionSettingsViewModel: ObservableObject { } } } - - private func updateSubscriptionDetails(status: Subscription.Status, date: Date, product: String) { + private func updateSubscriptionsStatusMessage(status: Subscription.Status, date: Date, product: String, billingPeriod: Subscription.BillingPeriod) { let statusString = (status == .autoRenewable) ? UserText.subscriptionRenews : UserText.subscriptionExpires self.subscriptionDetails = UserText.subscriptionInfo(status: statusString, expiration: dateFormatter.string(from: date)) - self.subscriptionType = product == Constants.monthlyProductID ? UserText.subscriptionMonthly : UserText.subscriptionAnnual + self.subscriptionType = billingPeriod == .monthly ? UserText.subscriptionMonthly : UserText.subscriptionAnnual } func removeSubscription() { @@ -103,22 +123,46 @@ final class SubscriptionSettingsViewModel: ObservableObject { presentationLocation: .withoutBottomBar) } - func manageSubscription() { - if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene { - Task { - do { - try await AppStore.showManageSubscriptions(in: windowScene) - } catch { - openSubscriptionManagementURL() - } - } + @MainActor private func manageAppleSubscription() async { + let url = URL.manageSubscriptionsInAppStoreAppURL + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene { + do { + try await AppStore.showManageSubscriptions(in: windowScene) + } catch { + self.openURL(url) + } + } else { + self.openURL(url) + } + } + + private func manageGoogleSubscription() { + shouldDisplayGoogleView = true + } + + private func manageStripeSubscription() async { + guard let token = accountManager.accessToken, let externalID = accountManager.externalID else { return } + let serviceResponse = await SubscriptionService.getCustomerPortalURL(accessToken: token, externalID: externalID) + + // Get Stripe Customer Portal URL and update the model + if case .success(let response) = serviceResponse { + guard let url = URL(string: response.customerPortalUrl) else { return } + if let existingModel = stripeViewModel { + existingModel.url = url } else { - openSubscriptionManagementURL() + let model = SubscriptionExternalLinkViewModel(url: url, allowedDomains: externalAllowedDomains) + DispatchQueue.main.async { + self.stripeViewModel = model + } } } + DispatchQueue.main.async { + self.shouldDisplayStripeView = true + } + } - private func openSubscriptionManagementURL() { - let url = URL.manageSubscriptionsInAppStoreAppURL + @MainActor + private func openURL(_ url: URL) { if UIApplication.shared.canOpenURL(url) { UIApplication.shared.open(url, options: [:], completionHandler: nil) } diff --git a/DuckDuckGo/Subscription/Views/SubscriptionExternalLinkView.swift b/DuckDuckGo/Subscription/Views/SubscriptionExternalLinkView.swift index badb1932bd..5edfcc0155 100644 --- a/DuckDuckGo/Subscription/Views/SubscriptionExternalLinkView.swift +++ b/DuckDuckGo/Subscription/Views/SubscriptionExternalLinkView.swift @@ -27,6 +27,7 @@ struct SubscriptionExternalLinkView: View { @Environment(\.dismiss) var dismiss @ObservedObject var viewModel: SubscriptionExternalLinkViewModel + @State var title: String? enum Constants { static let navButtonPadding: CGFloat = 20.0 @@ -47,6 +48,7 @@ struct SubscriptionExternalLinkView: View { } .navigationBarTitleDisplayMode(.inline) .navigationViewStyle(.stack) + .navigationTitle(title ?? "") .onAppear(perform: { setUpAppearances() diff --git a/DuckDuckGo/Subscription/Views/SubscriptionFlowView.swift b/DuckDuckGo/Subscription/Views/SubscriptionFlowView.swift index f611c5ead8..5df4f65ece 100644 --- a/DuckDuckGo/Subscription/Views/SubscriptionFlowView.swift +++ b/DuckDuckGo/Subscription/Views/SubscriptionFlowView.swift @@ -210,6 +210,7 @@ struct SubscriptionFlowView: View { message: Text(UserText.subscriptionFoundText), primaryButton: .cancel(Text(UserText.subscriptionFoundCancel)) { viewModel.transactionError = nil + viewModel.finalizeSubscriptionFlow() }, secondaryButton: .default(Text(UserText.subscriptionFoundRestore)) { viewModel.restoreAppstoreTransaction() diff --git a/DuckDuckGo/Subscription/Views/SubscriptionGoogleView.swift b/DuckDuckGo/Subscription/Views/SubscriptionGoogleView.swift new file mode 100644 index 0000000000..394ea90b58 --- /dev/null +++ b/DuckDuckGo/Subscription/Views/SubscriptionGoogleView.swift @@ -0,0 +1,66 @@ +// +// SubscriptionGoogleView.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 SwiftUI +#if SUBSCRIPTION +@available(iOS 15.0, *) + +struct SubscriptionGoogleView: View { + + enum Constants { + static let padding: CGFloat = 20.0 + } + + @Environment(\.dismiss) var dismiss + + var body: some View { + ZStack { + Color(designSystemColor: .background) + .edgesIgnoringSafeArea(.all) + VStack(alignment: .center) { + Image("google-play").padding(.top, Constants.padding) + + Text(UserText.subscriptionManageBillingGoogleText) + .daxSubheadRegular() + .foregroundColor(Color(designSystemColor: .textSecondary)) + .multilineTextAlignment(.center) + .padding(Constants.padding) + Spacer() + } + } + .navigationBarTitle(UserText.subscriptionManageBillingGoogleTitle, displayMode: .inline) + .applyInsetGroupedListStyle() + } + +} +#endif + + +#if SUBSCRIPTION && DEBUG +@available(iOS 15.0, *) + +struct SubscriptionGoogleView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + SubscriptionGoogleView().navigationBarTitleDisplayMode(.inline) + } + } +} +#endif diff --git a/DuckDuckGo/Subscription/Views/SubscriptionSettingsView.swift b/DuckDuckGo/Subscription/Views/SubscriptionSettingsView.swift index 76abb5875a..5760a2014a 100644 --- a/DuckDuckGo/Subscription/Views/SubscriptionSettingsView.swift +++ b/DuckDuckGo/Subscription/Views/SubscriptionSettingsView.swift @@ -35,7 +35,7 @@ struct SubscriptionSettingsView: View { @StateObject var viewModel = SubscriptionSettingsViewModel() @StateObject var sceneEnvironment = SceneEnvironment() @State var isFirstOnAppear = true - + @ViewBuilder private var optionsView: some View { List { @@ -65,6 +65,12 @@ struct SubscriptionSettingsView: View { isButton: true) } + .sheet(isPresented: $viewModel.shouldDisplayStripeView) { + if let stripeViewModel = viewModel.stripeViewModel { + SubscriptionExternalLinkView(viewModel: stripeViewModel, title: UserText.subscriptionManagePlan) + } + } + Section(header: Text(UserText.subscriptionManageDevices)) { NavigationLink(destination: SubscriptionRestoreView()) { @@ -92,6 +98,10 @@ struct SubscriptionSettingsView: View { }) } } + + NavigationLink(destination: SubscriptionGoogleView(), isActive: $viewModel.shouldDisplayGoogleView) { + EmptyView() + } } .navigationTitle(UserText.settingsPProManageSubscription) .applyInsetGroupedListStyle() @@ -122,6 +132,13 @@ struct SubscriptionSettingsView: View { } } + @ViewBuilder + private var stripeView: some View { + if let stripeViewModel = viewModel.stripeViewModel { + SubscriptionExternalLinkView(viewModel: stripeViewModel) + } + } + var body: some View { Group { if #available(iOS 16.0, *) { @@ -151,10 +168,6 @@ struct SubscriptionSettingsView_Previews: PreviewProvider { NavigationView { SubscriptionSettingsView().navigationBarTitleDisplayMode(.inline) } - // You can customize the preview environment here if needed. - // For example, you can set a specific device, size, or dark mode/light mode. - // .previewDevice(PreviewDevice(rawValue: "iPhone 12")) - // .preferredColorScheme(.dark) } } diff --git a/DuckDuckGo/UserText.swift b/DuckDuckGo/UserText.swift index 231e6fe716..fa1b65cba3 100644 --- a/DuckDuckGo/UserText.swift +++ b/DuckDuckGo/UserText.swift @@ -1139,6 +1139,10 @@ But if you *do* want a peek under the hood, you can find more information about public static let subscriptionBackendErrorMessage = NSLocalizedString("subscription.restore.backend.error.message", value: "We’re having trouble connecting. Please try again later.", comment: "Alert for general error message") public static let subscriptionBackendErrorButton = NSLocalizedString("subscription.restore.backend.error.button", value: "Back to Settings", comment: "Button text for general error message") + public static let subscriptionManageBillingGoogleTitle = NSLocalizedString("subscription.billing.google.title", value: "Subscription Plans", comment: "Title for the manage billing page") + public static let subscriptionManageBillingGoogleText = NSLocalizedString("subscription.billing.google.text", value: "Your subscription was purchased through the Google Play Store. To renew your subscription, please open Google Play Store subscription settings on a device signed in to the same Google Account used to originally purchase your subscription.", comment: "Text for the manage billing page") + + // PIR: public static let subscriptionPIRHeroText = NSLocalizedString("subscription.pir.hero", value: "Activate Privacy Pro on desktop to set up Personal Information Removal", comment: "Hero Text for Personal information removal") public static let subscriptionPIRHeroDetail = NSLocalizedString("subscription.pir.heroText", value: "In the DuckDuckGo browser for desktop, go to %@ and click %@ to get started.", comment: "Description on how to use Personal information removal in desktop. The first placeholder references a location in the Desktop application. Privacy Pro>, and the second, the menu entry. i.e. ") diff --git a/DuckDuckGo/en.lproj/Localizable.strings b/DuckDuckGo/en.lproj/Localizable.strings index 502d7847a4..be87fc63fa 100644 --- a/DuckDuckGo/en.lproj/Localizable.strings +++ b/DuckDuckGo/en.lproj/Localizable.strings @@ -2037,6 +2037,12 @@ But if you *do* want a peek under the hood, you can find more information about /* Subscription availability message on Apple devices */ "subscription.available.apple" = "Privacy Pro is available on any device signed in to the same Apple ID."; +/* Text for the manage billing page */ +"subscription.billing.google.text" = "Your subscription was purchased through the Google Play Store. To renew your subscription, please open Google Play Store subscription settings on a device signed in to the same Google Account used to originally purchase your subscription."; + +/* Title for the manage billing page */ +"subscription.billing.google.title" = "Subscription Plans"; + /* Subscription Removal confirmation message */ "subscription.cancel.message" = "Your subscription has been removed from this device.";