Skip to content
This repository has been archived by the owner on May 10, 2024. It is now read-only.

Commit

Permalink
Fix #8001: Display the VPN subscription in the App Store (#8415)
Browse files Browse the repository at this point in the history
  • Loading branch information
soner-yuksel authored Nov 17, 2023
1 parent 1f21595 commit 8a467bc
Show file tree
Hide file tree
Showing 11 changed files with 195 additions and 5 deletions.
10 changes: 9 additions & 1 deletion App/iOS/Delegates/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions App/iOS/Delegates/SceneDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<UIOpenURLContext>) {
Expand Down
26 changes: 26 additions & 0 deletions Sources/Brave/Frontend/Browser/BrowserViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import BraveCore
import UIKit
import Onboarding
import BraveShields
import BraveVPN
import StoreKit

// MARK: - Onboarding

Expand All @@ -18,6 +20,7 @@ extension BrowserViewController {
func presentOnboardingIntro() {
if Preferences.DebugFlag.skipOnboardingIntro == true { return }

Preferences.AppState.isOnboardingActive.value = true
presentOnboardingWelcomeScreen(on: self)
}

Expand All @@ -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
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -89,20 +96,35 @@ 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)
}
}
)
}

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 {
Expand Down Expand Up @@ -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)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 }
Expand Down
91 changes: 91 additions & 0 deletions Sources/BraveVPN/BraveVPN.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 0 additions & 2 deletions Sources/BraveVPN/BraveVPNPreferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@ import Preferences
extension Preferences {
final public class VPN {
public static let popupShowed = Option<Bool>(key: "vpn.popup-showed", default: false)
/// We get it from Guardian's servers.
public static let lastPurchaseProductId = Option<String?>(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.
Expand Down
4 changes: 4 additions & 0 deletions Sources/BraveVPN/BraveVPNSettingsViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions Sources/BraveVPN/BuyVPNViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
25 changes: 24 additions & 1 deletion Sources/BraveVPN/IAPObserver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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))
Expand All @@ -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
}
}
5 changes: 5 additions & 0 deletions Sources/Preferences/GlobalPreferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String?>(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<Bool>(key: "appstate.onboarding-active", default: false)
}

public final class Chromium {
Expand Down

0 comments on commit 8a467bc

Please sign in to comment.