From 5c7d21a7c0c55e7406fd0bbee68a4ab0998a1f2c Mon Sep 17 00:00:00 2001 From: Soner YUKSEL Date: Fri, 17 Nov 2023 13:30:21 -0500 Subject: [PATCH] Fix brave/brave-ios#8001: Display the VPN subscription in the App Store (brave/brave-ios#8415) --- App/iOS/Delegates/AppDelegate.swift | 10 +- App/iOS/Delegates/SceneDelegate.swift | 1 + .../Browser/BrowserViewController.swift | 26 ++++++ .../BrowserViewController+Onboarding.swift | 25 ++++- .../Helpers/BrowserNavigationHelper.swift | 7 ++ Sources/BraveVPN/BraveVPN.swift | 91 +++++++++++++++++++ Sources/BraveVPN/BraveVPNPreferences.swift | 2 - .../BraveVPNSettingsViewController.swift | 4 + Sources/BraveVPN/BuyVPNViewController.swift | 4 + Sources/BraveVPN/IAPObserver.swift | 25 ++++- Sources/Preferences/GlobalPreferences.swift | 5 + 11 files changed, 195 insertions(+), 5 deletions(-) diff --git a/App/iOS/Delegates/AppDelegate.swift b/App/iOS/Delegates/AppDelegate.swift index dfc9a4908fb4..af821bda8e15 100644 --- a/App/iOS/Delegates/AppDelegate.swift +++ b/App/iOS/Delegates/AppDelegate.swift @@ -116,7 +116,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // IAPs can trigger on the app as soon as it launches, // for example when a previous transaction was not finished and is in pending state. SKPaymentQueue.default().add(BraveVPN.iapObserver) - + // Editing Product Promotion List + Task { @MainActor in + await BraveVPN.updateStorePromotionOrder() + await BraveVPN.hideActiveStorePromotion() + } + // Override point for customization after application launch. var shouldPerformAdditionalDelegateHandling = true AdblockEngine.setDomainResolver() @@ -143,6 +148,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate { Preferences.Review.launchCount.value += 1 let isFirstLaunch = Preferences.General.isFirstLaunch.value + + Preferences.AppState.isOnboardingActive.value = isFirstLaunch + if Preferences.Onboarding.basicOnboardingCompleted.value == OnboardingState.undetermined.rawValue { Preferences.Onboarding.basicOnboardingCompleted.value = isFirstLaunch ? OnboardingState.unseen.rawValue : OnboardingState.completed.rawValue diff --git a/App/iOS/Delegates/SceneDelegate.swift b/App/iOS/Delegates/SceneDelegate.swift index 26d4e908ee61..6db6e29cba27 100644 --- a/App/iOS/Delegates/SceneDelegate.swift +++ b/App/iOS/Delegates/SceneDelegate.swift @@ -233,6 +233,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { func sceneDidEnterBackground(_ scene: UIScene) { AppState.shared.profile.shutdown() BraveVPN.sendVPNWorksInBackgroundNotification() + Preferences.AppState.isOnboardingActive.value = false } func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { diff --git a/Sources/Brave/Frontend/Browser/BrowserViewController.swift b/Sources/Brave/Frontend/Browser/BrowserViewController.swift index 8a23233c1170..d724f3fd12b2 100644 --- a/Sources/Brave/Frontend/Browser/BrowserViewController.swift +++ b/Sources/Brave/Frontend/Browser/BrowserViewController.swift @@ -264,6 +264,9 @@ public class BrowserViewController: UIViewController { var processAddressBarTask: Task<(), Never>? var topToolbarDidPressReloadTask: Task<(), Never>? + + /// In app purchase obsever for VPN Subscription action + let iapObserver: IAPObserver public init( windowId: UUID, @@ -324,9 +327,13 @@ public class BrowserViewController: UIViewController { if Locale.current.regionCode == "JP" { benchmarkBlockingDataSource = BlockingSummaryDataSource() } + + iapObserver = BraveVPN.iapObserver super.init(nibName: nil, bundle: nil) didInit() + + iapObserver.delegate = self rewards.rewardsServiceDidStart = { [weak self] _ in self?.setupLedger() @@ -3354,3 +3361,22 @@ extension BrowserViewController { self.present(host, animated: true) } } + +extension BrowserViewController: IAPObserverDelegate { + public func purchasedOrRestoredProduct(validateReceipt: Bool) { + // No-op + } + + public func purchaseFailed(error: IAPObserver.PurchaseError) { + // No-op + } + + public func handlePromotedInAppPurchase() { + // Open VPN Buy Screen before system triggers buy action + // Delaying the VPN Screen launch delibrately to syncronize promoted purchase launch + Task.delayed(bySeconds: 2.0) { @MainActor in + self.popToBVC() + self.navigationHelper.openVPNBuyScreen(iapObserver: self.iapObserver) + } + } +} diff --git a/Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+Onboarding.swift b/Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+Onboarding.swift index 363c3203fc4f..d286ea27dea2 100644 --- a/Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+Onboarding.swift +++ b/Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+Onboarding.swift @@ -10,6 +10,8 @@ import BraveCore import UIKit import Onboarding import BraveShields +import BraveVPN +import StoreKit // MARK: - Onboarding @@ -18,6 +20,7 @@ extension BrowserViewController { func presentOnboardingIntro() { if Preferences.DebugFlag.skipOnboardingIntro == true { return } + Preferences.AppState.isOnboardingActive.value = true presentOnboardingWelcomeScreen(on: self) } @@ -27,6 +30,7 @@ extension BrowserViewController { // 1. Existing user. // 2. User already completed onboarding. if Preferences.Onboarding.basicOnboardingCompleted.value == OnboardingState.completed.rawValue { + Preferences.AppState.isOnboardingActive.value = false return } @@ -54,6 +58,9 @@ extension BrowserViewController { } func showNTPOnboarding() { + Preferences.AppState.isOnboardingActive.value = false + iapObserver.savedPayment = nil + if !topToolbar.inOverlayMode, topToolbar.currentURL == nil, Preferences.DebugFlag.skipNTPCallouts != true { @@ -89,13 +96,19 @@ extension BrowserViewController { presentPopoverContent( using: controller, with: frame, cornerRadius: 6.0, - didDismiss: { + didDismiss: { [weak self] in + guard let self = self else { return } + Preferences.FullScreenCallout.omniboxCalloutCompleted.value = true + Preferences.AppState.isOnboardingActive.value = false + + self.triggerPromotedInAppPurchase(savedPayment: self.iapObserver.savedPayment) }, didClickBorderedArea: { [weak self] in guard let self = self else { return } Preferences.FullScreenCallout.omniboxCalloutCompleted.value = true + Preferences.AppState.isOnboardingActive.value = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { self.topToolbar.tabLocationViewDidTapLocation(self.topToolbar.locationView) @@ -103,6 +116,15 @@ extension BrowserViewController { } ) } + + private func triggerPromotedInAppPurchase(savedPayment: SKPayment?) { + guard let productPayment = savedPayment else { + return + } + + navigationHelper.openVPNBuyScreen(iapObserver: iapObserver) + BraveVPN.activatePaymentTypeForStoredPromotion(savedPayment: productPayment) + } private func showPrivacyReportsOnboardingIfNeeded() { if Preferences.PrivacyReports.ntpOnboardingCompleted.value || privateBrowsingManager.isPrivateBrowsing { @@ -279,6 +301,7 @@ extension BrowserViewController { func completeOnboarding(_ controller: UIViewController) { Preferences.Onboarding.basicOnboardingCompleted.value = OnboardingState.completed.rawValue + Preferences.AppState.isOnboardingActive.value = false controller.dismiss(animated: true) } } diff --git a/Sources/Brave/Frontend/Browser/Helpers/BrowserNavigationHelper.swift b/Sources/Brave/Frontend/Browser/Helpers/BrowserNavigationHelper.swift index 1c03af38254b..5b58563ff6b1 100644 --- a/Sources/Brave/Frontend/Browser/Helpers/BrowserNavigationHelper.swift +++ b/Sources/Brave/Frontend/Browser/Helpers/BrowserNavigationHelper.swift @@ -6,6 +6,7 @@ import Foundation import Shared import UIKit +import BraveVPN /// Handles displaying controllers such as settings, bookmarks, etc. on top of /// the browser. @@ -66,6 +67,12 @@ class BrowserNavigationHelper { open(vc, doneButton: DoneButton(style: .done, position: .right)) } + + func openVPNBuyScreen(iapObserver: IAPObserver) { + guard let vc = BraveVPN.vpnState.enableVPNDestinationVC else { return } + + open(vc, doneButton: DoneButton(style: .done, position: .left)) + } func openShareSheet() { guard let bvc = bvc else { return } diff --git a/Sources/BraveVPN/BraveVPN.swift b/Sources/BraveVPN/BraveVPN.swift index 3b083059dfb9..3e42910c1dee 100644 --- a/Sources/BraveVPN/BraveVPN.swift +++ b/Sources/BraveVPN/BraveVPN.swift @@ -295,6 +295,29 @@ public class BraveVPN { helper.mainCredential?.hostnameDisplayValue } + /// Type of the vpn subscription + public enum SubscriptionType: Equatable { + case monthly, yearly, other + } + + /// Type of the active purchased vpn plan + public static var activeSubscriptionType: SubscriptionType { + guard let credential = GRDSubscriberCredential.current() else { + logAndStoreError("subscriptionName: failed to retrieve subscriber credentials") + return .other + } + let productId = credential.subscriptionType + + switch productId { + case VPNProductInfo.ProductIdentifiers.monthlySub: + return .monthly + case VPNProductInfo.ProductIdentifiers.yearlySub: + return .yearly + default: + return .other + } + } + /// Name of the purchased vpn plan. public static var subscriptionName: String { guard let credential = GRDSubscriberCredential.current() else { @@ -650,6 +673,74 @@ public class BraveVPN { } } + // MARK: - Promotion + + /// Editing product promotion order first yearly and monthly after + @MainActor public static func updateStorePromotionOrder() async { + let storePromotionController = SKProductStorePromotionController.default() + // Fetch Products + guard let yearlyProduct = VPNProductInfo.yearlySubProduct, + let monthlyProduct = VPNProductInfo.monthlySubProduct else { + Logger.module.debug("Found empty while fetching SKProducts for promotion order") + return + } + + // Update the order + do { + try await storePromotionController.update(promotionOrder: [yearlyProduct, monthlyProduct]) + } catch { + Logger.module.debug("Error while opdating product promotion order ") + } + } + + /// Hiding Store pormotion if the active subscription for the type + @MainActor public static func hideActiveStorePromotion() async { + let storePromotionController = SKProductStorePromotionController.default() + + // Fetch Products + guard let yearlyProduct = VPNProductInfo.yearlySubProduct, + let monthlyProduct = VPNProductInfo.monthlySubProduct else { + Logger.module.debug("Found empty while fetching SKProducts for promotion order") + return + } + + // No promotion for VPN is purchased through website side + if Preferences.VPN.skusCredential.value != nil { + await hideSubscriptionType(yearlyProduct) + await hideSubscriptionType(monthlyProduct) + + return + } + + // Hide the promotion + let activeSubscriptionType = BraveVPN.activeSubscriptionType + + switch activeSubscriptionType { + case .monthly: + await hideSubscriptionType(monthlyProduct) + case .yearly: + await hideSubscriptionType(yearlyProduct) + default: + break + } + + func hideSubscriptionType(_ product: SKProduct) async { + do { + try await storePromotionController.update(promotionVisibility: .hide, for: product) + } catch { + Logger.module.debug("Error while opdating product promotion order ") + } + } + } + + public static func activatePaymentTypeForStoredPromotion(savedPayment: SKPayment?) { + if let payment = savedPayment { + SKPaymentQueue.default().add(payment) + } + + iapObserver.savedPayment = nil + } + // MARK: - Error Handling /// Stores a in-memory list of vpn errors encountered during current browsing session. diff --git a/Sources/BraveVPN/BraveVPNPreferences.swift b/Sources/BraveVPN/BraveVPNPreferences.swift index 9220c3de9a06..cc01992495b9 100644 --- a/Sources/BraveVPN/BraveVPNPreferences.swift +++ b/Sources/BraveVPN/BraveVPNPreferences.swift @@ -9,8 +9,6 @@ import Preferences extension Preferences { final public class VPN { public static let popupShowed = Option(key: "vpn.popup-showed", default: false) - /// We get it from Guardian's servers. - public static let lastPurchaseProductId = Option(key: "vpn.last-purchase-id", default: nil) /// When the current subscription plan expires. It is nil if the user has not bought any vpn plan yet. /// In case of receipt expiration this date might be set to some old date(like year 1970) /// to make sure vpn expiration logic will be called. diff --git a/Sources/BraveVPN/BraveVPNSettingsViewController.swift b/Sources/BraveVPN/BraveVPNSettingsViewController.swift index 8485ec8182a3..9805764ccd24 100644 --- a/Sources/BraveVPN/BraveVPNSettingsViewController.swift +++ b/Sources/BraveVPN/BraveVPNSettingsViewController.swift @@ -372,6 +372,10 @@ extension BraveVPNSettingsViewController: IAPObserverDelegate { handleOfferCodeError(error: error) } + public func handlePromotedInAppPurchase() { + // No-op + } + private func handleOfferCodeError(error: IAPObserver.PurchaseError) { DispatchQueue.main.async { self.isLoading = false diff --git a/Sources/BraveVPN/BuyVPNViewController.swift b/Sources/BraveVPN/BuyVPNViewController.swift index 83f105000804..29e7e2237958 100644 --- a/Sources/BraveVPN/BuyVPNViewController.swift +++ b/Sources/BraveVPN/BuyVPNViewController.swift @@ -224,6 +224,10 @@ extension BuyVPNViewController: IAPObserverDelegate { handleTransactionError(error: error) } + func handlePromotedInAppPurchase() { + // No-op In app purchase promotion is handled on bvc + } + @objc func handleRestoreTimeoutFailure() { // Handle Restore error from timeout guard isLoading else { diff --git a/Sources/BraveVPN/IAPObserver.swift b/Sources/BraveVPN/IAPObserver.swift index 994962eb056e..83970252ec87 100644 --- a/Sources/BraveVPN/IAPObserver.swift +++ b/Sources/BraveVPN/IAPObserver.swift @@ -7,10 +7,12 @@ import Foundation import StoreKit import Shared import os.log +import Preferences public protocol IAPObserverDelegate: AnyObject { func purchasedOrRestoredProduct(validateReceipt: Bool) func purchaseFailed(error: IAPObserver.PurchaseError) + func handlePromotedInAppPurchase() } public class IAPObserver: NSObject, SKPaymentTransactionObserver { @@ -21,7 +23,10 @@ public class IAPObserver: NSObject, SKPaymentTransactionObserver { } public weak var delegate: IAPObserverDelegate? - + public var savedPayment: SKPayment? + + // MARK: - Handling transactions + public func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) { // This helper variable helps to call the IAPObserverDelegate delegate purchased method only once. // Reason is when restoring or sometimes when purchasing or restoring a product there's multiple transactions @@ -81,6 +86,8 @@ public class IAPObserver: NSObject, SKPaymentTransactionObserver { } } + // MARK: - Restoring Transactions + public func paymentQueue(_ queue: SKPaymentQueue, restoreCompletedTransactionsFailedWithError error: Error) { Logger.module.debug("Restoring transaction failed") self.delegate?.purchaseFailed(error: .transactionError(error: error as? SKError)) @@ -95,4 +102,20 @@ public class IAPObserver: NSObject, SKPaymentTransactionObserver { delegate?.purchaseFailed(error: .transactionError(error: errorRestore)) } } + + // MARK: - Handling promoted in-app purchases + + public func paymentQueue(_ queue: SKPaymentQueue, shouldAddStorePayment payment: SKPayment, for product: SKProduct) -> Bool { + // Check if ther eis an active onboarding happening + let shouldDeferPayment = Preferences.AppState.isOnboardingActive.value + + // If you need to defer until onboarding is complete, save the payment and return false. + if shouldDeferPayment { + savedPayment = payment + return false + } + + delegate?.handlePromotedInAppPurchase() + return true + } } diff --git a/Sources/Preferences/GlobalPreferences.swift b/Sources/Preferences/GlobalPreferences.swift index 93e2f22d84db..36838f7916b2 100644 --- a/Sources/Preferences/GlobalPreferences.swift +++ b/Sources/Preferences/GlobalPreferences.swift @@ -135,6 +135,11 @@ extension Preferences { /// and therefore we can try to load them right away and have them ready on the first tab load @MainActor public static let lastFilterListCatalogueComponentFolderPath = Option(key: "caching.last-filter-list-catalogue-component-folder-path", default: nil) + + /// A cached value for indicating if onboarding is actively going on + /// + /// This is used to determine if a promoted purchase from store can be triggered and shown user + public static let isOnboardingActive = Option(key: "appstate.onboarding-active", default: false) } public final class Chromium {