Files
lockdown-iOS-mirror/LockdowniOS/VPNSubscription.swift
2024-12-11 16:36:25 +02:00

532 lines
24 KiB
Swift

//
// VPNSubscription.swift
// Lockdown
//
// Copyright © 2018 Confirmed, Inc. All rights reserved.
//
import UIKit
import SwiftyStoreKit
import PromiseKit
import CocoaLumberjackSwift
import StoreKit
struct OneTimeProducts: ToList {
let weekly: String
let weeklyTrial: String
let yearly: String
let yearlyTrial: String
}
struct SpecialOfferProducts: ToList {
let yearly: String
}
struct FeedbackProducts: ToList {
let weekly: String
let yearly: String
}
protocol ToList {
func toList() -> [String]
}
extension ToList {
func toList() -> [String] {
let otherSelf = Mirror(reflecting: self)
return otherSelf.children.compactMap {
$0.value as? String
}
}
}
struct InternalSubscription: Hashable {
let productId: String
let period: SKProduct.PeriodUnit
let trialDuration: String?
let priceLocale: Locale
let price: NSDecimalNumber
let offer: NSDecimalNumber?
static var mockWeekly: InternalSubscription {
InternalSubscription(productId: "lockdown.weekly.1.202408.no_trial.4hrs_offer", period: .week, trialDuration: nil, priceLocale: .current, price: 0.99, offer: nil)
}
static var mockWeeklyTrial: InternalSubscription {
InternalSubscription(productId: "lockdown.weekly.1.202408.3_days_free_trial.4hrs_offer", period: .week, trialDuration: "3 days", priceLocale: .current, price: 0.99, offer: nil)
}
static var mockYearly: InternalSubscription {
InternalSubscription(productId: "lockdown.yearly.40.202408.no_trial.4hrs_offer_", period: .year, trialDuration: nil, priceLocale: .current, price: 39.99, offer: nil)
}
static var mockYearlTrial: InternalSubscription {
InternalSubscription(productId: "lockdown.yearly.40.202408.3_days_free_trial.4hrs_offer", period: .year, trialDuration: "3 days", priceLocale: .current, price: 39.99, offer: nil)
}
static var mockYearlyBF: InternalSubscription {
InternalSubscription(productId: "lockdown.yearly.30.202412.1_year_no_trial.4h_screen_holiday", period: .year, trialDuration: nil, priceLocale: .current, price: 99.99, offer: 29.99)
}
}
enum SubscriptionState: Int {
case Uninitialized = 1, Subscribed, NotSubscribed
}
actor VPNSubscription: NSObject {
enum SubscriptionType {
case oneTime
case feedback
case specialOffer
var productIds: [String] {
switch self {
case .oneTime: VPNSubscription.oneTimeProducts.toList()
case .feedback: VPNSubscription.feedbackProducts.toList()
case .specialOffer: VPNSubscription.specialOfferProducts.toList()
}
}
}
private var cachedSubscriptions: [SubscriptionType: [InternalSubscription]] = [:]
static var shared = VPNSubscription()
static let productIdAdvancedMonthly = "LockdowniOSFirewallMonthly"
static let productIdAdvancedYearly = "LockdowniOSFirewallAnnual"
static let productIdMonthly = "LockdowniOSVpnMonthly"
static let productIdAnnual = "LockdowniOSVpnAnnual"
static let productIdMonthlyPro = "LockdowniOSVpnMonthlyPro"
static let productIdAnnualPro = "LockdowniOSVpnAnnualPro"
static let productIds: Set = [productIdAdvancedMonthly, productIdAdvancedYearly, productIdMonthly, productIdAnnual, productIdMonthlyPro, productIdAnnualPro]
static let oneTimeProducts = OneTimeProducts(weekly: "lockdown.weekly.1.202408.no_trial.4hrs_offer",
weeklyTrial: "lockdown.weekly.1.202408.3_days_free_trial.4hrs_offer",
yearly: "lockdown.yearly.40.202408.no_trial.4hrs_offer_",
yearlyTrial: "lockdown.yearly.40.202408.3_days_free_trial.4hrs_offer")
static let feedbackProducts = FeedbackProducts(weekly: "lockdown.weekly.1.202409.no_trial.feedback",
yearly: "lockdown.yearly.40.202409.no_trial.feedback")
static let specialOfferProducts = SpecialOfferProducts(yearly: "lockdown.yearly.30.202412.1_year_no_trial.4h_screen_holiday")
static var selectedProductId = productIdAdvancedMonthly
// Advanced Level
static var defaultPriceStringAdvancedMonthly = "$4.99"
static var defaultPriceStringAdvancedYearly = "$29.99"
static var defaultPriceSubStringAdvancedYearly = "$2.49"
static var defaultUpgradePriceStringAdvancedMonthly = "$4.99"
static var defaultUpgradePriceStringAdvancedYearly = "$29.99"
// Anonymous Level
static var defaultPriceStringMonthly = "$8.99"
static var defaultPriceStringAnnual = "$59.99"
static var defaultPriceSubStringAnnual = "$4.99"
static var defaultUpgradePriceStringAnnual = "$59.99"
static var defaultUpgradePriceStringMonthly = "$8.99"
// Universal Level
static var defaultPriceStringMonthlyPro = "$11.99"
static var defaultPriceStringAnnualPro = "$99.99"
static var defaultPriceStringAnnualPro70Off = "$29.99"
static var defaultPriceSubStringAnnualPro = "$8.33"
static var defaultUpgradePriceStringAnnualPro = "$99.99"
static var defaultUpgradePriceStringMonthlyPro = "$11.99"
@discardableResult
func loadSubscriptions(type: SubscriptionType) async -> [InternalSubscription]? {
if let subscriptions = cachedSubscriptions[type], !subscriptions.isEmpty {
return subscriptions
}
cachedSubscriptions[type] = await _loadSubscriptions(productIds: Set(type.productIds))
return cachedSubscriptions[type]
}
private func _loadSubscriptions(productIds: Set<String>) async -> [InternalSubscription]? {
DDLogInfo("cache localized price for productIds: \(productIds)")
let currencyFormatter = NumberFormatter()
currencyFormatter.usesGroupingSeparator = true
currencyFormatter.numberStyle = .currency
return await withCheckedContinuation { continuation in
SwiftyStoreKit.retrieveProductsInfo(productIds) { result in
DDLogInfo("retrieve products results: \(result)")
var subs = [InternalSubscription]()
for product in result.retrievedProducts {
let period = Self.subscriptionPeriod(product: product)
let trialDuration = Self.trialDuraion(for: product.introductoryPrice)
currencyFormatter.locale = product.priceLocale
if let period {
let ip = InternalSubscription(productId: product.productIdentifier,
period: period,
trialDuration: trialDuration,
priceLocale: product.priceLocale,
price: product.price,
offer: product.price)
subs.append(ip)
}
}
continuation.resume(returning: subs)
}
}
}
private override init() {
super.init()
}
static func purchase(succeeded: @escaping () -> Void, errored: @escaping (Error) -> Void) {
DDLogInfo("purchase")
SwiftyStoreKit.purchaseProduct(selectedProductId, atomically: true) { result in
switch result {
case .success:
firstly {
try Client.signIn()
}
.then { (signin: SignIn) -> Promise<GetKey> in
try Client.getKey()
}
.done { (getKey: GetKey) in
try setVPNCredentials(id: getKey.id, keyBase64: getKey.b64)
BaseUserService.shared.user.resetCache()
succeeded()
}
.catch { error in
errored(error)
}
case .error(let error):
DDLogError("purchase error: \(error)")
errored(error)
}
}
}
static func subscriptionPeriod(product: SKProduct) -> SKProduct.PeriodUnit? {
guard let subscriptionPeriod = product.subscriptionPeriod else {
return nil
}
return subscriptionPeriod.unit
}
static func setTrialDuration(productId: String, duration: String?) {
let trialKey = productId + "Trial"
guard let duration else {
UserDefaults.standard.removeObject(forKey: trialKey)
DDLogInfo("Unavaible trial for \(productId)")
return
}
DDLogInfo("Setting trial a duration \(duration) for \(productId)")
UserDefaults.standard.set(duration, forKey: trialKey)
}
static func setProductIdPrice(productId: String, price: String) {
DDLogInfo("Setting product id price \(price) for \(productId)")
UserDefaults.standard.set(price, forKey: productId + "Price")
}
static func setProductIdUpgradePrice(productId: String, upgradePrice: String) {
DDLogInfo("Setting product id upgrade price \(upgradePrice) for \(productId)")
UserDefaults.standard.set(upgradePrice, forKey: productId + "UpgradePrice")
}
static func setProductIdPriceAnnualMonthly(productId: String, price: String) {
DDLogInfo("Setting product id price yearly per month \(price) for \(productId)")
UserDefaults.standard.set(price, forKey: productId + "MonthlyPrice")
}
static func setProductIdUpgradePriceAnnualMonthly(productId: String, price: String) {
DDLogInfo("Setting product id upgrade price yearly per month\(price) for \(productId)")
UserDefaults.standard.set(price, forKey: productId + "MonthlyUpgradePrice")
}
enum SubscriptionContext {
case new
case upgrade
case monthlyNew
case monthlyUpgrade
}
static func getProductIdPrice(productId: String, for context: SubscriptionContext) -> String {
switch context {
case .new:
return getProductIdPrice(productId: productId)
case .upgrade:
return getProductIdUpgradePrice(productId: productId)
case .monthlyNew:
return getProductIdPrice(productId: productId)
case .monthlyUpgrade:
return getProductIdUpgradePrice(productId: productId)
}
}
static func getProductIdPriceMonthly(productId: String) -> String {
DDLogInfo("Getting product id price yearly per month \(productId)")
if let price = UserDefaults.standard.string(forKey: productId + "MonthlyPrice") {
DDLogInfo("Got product id price yearly per month for \(productId): \(price)")
return price
}
else {
DDLogError("Found no cached price yearly per month for productId \(productId), returning default")
switch productId {
case productIdAdvancedYearly:
return defaultPriceSubStringAdvancedYearly
case productIdAnnual:
return defaultPriceSubStringAnnual
case productIdAnnualPro:
return defaultPriceSubStringAnnualPro
default:
DDLogError("Invalid product Id: \(productId)")
return "Invalid Price"
}
}
}
static func trialDuration(productId: String) -> String? {
UserDefaults.standard.string(forKey: productId + "Trial")
}
static func getProductIdPrice(productId: String) -> String {
DDLogInfo("Getting product id price for \(productId)")
if let price = UserDefaults.standard.string(forKey: productId + "Price") {
DDLogInfo("Got product id price for \(productId): \(price)")
return price
}
else {
DDLogError("Found no cached price for productId \(productId), returning default")
switch productId {
case productIdAdvancedMonthly:
return defaultPriceStringAdvancedMonthly
case productIdAdvancedYearly:
return defaultPriceStringAdvancedYearly
case productIdMonthly:
return defaultPriceStringMonthly
case productIdMonthlyPro:
return defaultPriceStringMonthlyPro
case productIdAnnual:
return defaultPriceStringAnnual
case productIdAnnualPro:
return defaultPriceStringAnnualPro
default:
DDLogError("Invalid product Id: \(productId)")
return "Invalid Price"
}
}
}
static func getProductIdUpgradePrice(productId: String) -> String {
DDLogInfo("Getting product id upgrade price for \(productId)")
if let upgradePrice = UserDefaults.standard.string(forKey: productId + "UpgradePrice") {
DDLogInfo("Got product id upgrade price for \(productId): \(upgradePrice)")
return upgradePrice
}
else {
DDLogError("Found no cached upgrade price for productId \(productId), returning default")
switch productId {
case productIdAdvancedMonthly:
return defaultUpgradePriceStringAdvancedMonthly
case productIdAdvancedYearly:
return defaultUpgradePriceStringAdvancedYearly
case productIdMonthly:
return defaultUpgradePriceStringMonthly
case productIdMonthlyPro:
return defaultUpgradePriceStringMonthlyPro
case productIdAnnual:
return defaultUpgradePriceStringAnnual
case productIdAnnualPro:
return defaultUpgradePriceStringAnnualPro
default:
DDLogError("Invalid product Id: \(productId)")
return "Invalid Upgrade Price"
}
}
}
static func cacheLocalizedPrices() -> Void {
let currencyFormatter = NumberFormatter()
currencyFormatter.usesGroupingSeparator = true
currencyFormatter.numberStyle = .currency
DDLogInfo("cache localized price for productIds: \(productIds)")
SwiftyStoreKit.retrieveProductsInfo(productIds) { result in
DDLogInfo("retrieve products results: \(result)")
for product in result.retrievedProducts {
DDLogInfo("product locale: \(product.priceLocale)")
DDLogInfo("productprice: \(product.localizedPrice ?? "n/a")")
if product.productIdentifier == productIdAdvancedMonthly {
if product.localizedPrice != nil {
DDLogInfo("setting productIdAdvancedMonthly display price = " + product.localizedPrice!)
setProductIdPrice(productId: productIdAdvancedMonthly, price: "\(product.localizedPrice!)")
setProductIdUpgradePrice(productId: productIdAdvancedMonthly, upgradePrice: "\(product.localizedPrice!)")
}
else {
DDLogError("monthly nil localizedPrice, setting default")
setProductIdPrice(productId: productIdAdvancedMonthly, price: defaultPriceStringAdvancedMonthly)
setProductIdUpgradePrice(productId: productIdAdvancedMonthly, upgradePrice: defaultUpgradePriceStringAdvancedMonthly)
}
}
else if product.productIdentifier == productIdAdvancedYearly {
if product.localizedPrice != nil {
currencyFormatter.locale = product.priceLocale
let priceMonthly = product.price.dividing(by: 12)
if let priceString = currencyFormatter.string(from: priceMonthly) {
setProductIdPriceAnnualMonthly(productId: productIdAdvancedYearly, price: priceString)
DDLogInfo("setting productIdAdvancedAnnualMonthly display price = " + priceString)
}
DDLogInfo("setting productIdAdvancedYearly display price = " + product.localizedPrice!)
setProductIdPrice(productId: productIdAdvancedYearly, price: "\(product.localizedPrice!)")
setProductIdUpgradePrice(productId: productIdAdvancedYearly, upgradePrice: "\(product.localizedPrice!)")
}
else {
DDLogError("monthly nil localizedPrice, setting default")
setProductIdPrice(productId: productIdAdvancedYearly, price: defaultPriceStringAdvancedYearly)
setProductIdUpgradePrice(productId: productIdAdvancedYearly, upgradePrice: defaultUpgradePriceStringAdvancedYearly)
setProductIdPriceAnnualMonthly(productId: productIdAdvancedYearly, price: defaultPriceSubStringAdvancedYearly)
}
}
else if product.productIdentifier == productIdAnnual {
if product.localizedPrice != nil {
currencyFormatter.locale = product.priceLocale
let priceMonthly = product.price.dividing(by: 12)
if let priceString = currencyFormatter.string(from: priceMonthly) {
setProductIdPriceAnnualMonthly(productId: productIdAnnual, price: priceString)
DDLogInfo("setting productIdAnnualAnnualMonthly display price = " + priceString)
}
DDLogInfo("setting productIdAnnualAnnual display price = annual product price / 12 = " + product.localizedPrice!)
setProductIdPrice(productId: productIdAnnual, price: "\(product.localizedPrice!)")
setProductIdUpgradePrice(productId: productIdAnnual, upgradePrice: "\(product.localizedPrice!)")
}
else {
DDLogError("unable to format price with currencyformatter: " + product.price.stringValue)
setProductIdPrice(productId: productIdAnnual, price: defaultPriceStringAnnual)
setProductIdUpgradePrice(productId: productIdAnnual, upgradePrice: defaultUpgradePriceStringAnnual)
setProductIdPriceAnnualMonthly(productId: productIdAnnual, price: defaultPriceSubStringAnnual)
}
}
else if product.productIdentifier == productIdMonthly {
if product.localizedPrice != nil {
DDLogInfo("setting productIdMonthly display price = " + product.localizedPrice!)
setProductIdPrice(productId: productIdMonthly, price: "\(product.localizedPrice!)")
setProductIdUpgradePrice(productId: productIdMonthly, upgradePrice: "\(product.localizedPrice!)")
}
else {
DDLogError("monthly nil localizedPrice, setting default")
setProductIdPrice(productId: productIdMonthly, price: defaultPriceStringMonthly)
setProductIdUpgradePrice(productId: productIdMonthly, upgradePrice: defaultUpgradePriceStringMonthly)
}
}
else if product.productIdentifier == productIdMonthlyPro {
if product.localizedPrice != nil {
DDLogInfo("setting productIdMonthlyPro display price = " + product.localizedPrice!)
setProductIdPrice(productId: productIdMonthlyPro, price: "\(product.localizedPrice!)")
setProductIdUpgradePrice(productId: productIdMonthlyPro, upgradePrice: "\(product.localizedPrice!)")
}
else {
DDLogError("monthlyPro nil localizedPrice, setting default")
setProductIdPrice(productId: productIdMonthlyPro, price: defaultPriceStringMonthlyPro)
setProductIdUpgradePrice(productId: productIdMonthlyPro, upgradePrice: defaultUpgradePriceStringMonthlyPro)
}
}
else if product.productIdentifier == productIdAnnualPro {
if product.localizedPrice != nil {
currencyFormatter.locale = product.priceLocale
let priceMonthly = product.price.dividing(by: 12)
if let priceString = currencyFormatter.string(from: priceMonthly) {
DDLogInfo("setting productIdAnnualPro display price = annualPro product price / 12 = " + priceString)
setProductIdPriceAnnualMonthly(productId: productIdAnnualPro, price: priceString)
}
DDLogInfo("setting productIdAnnualPro display price = " + product.localizedPrice!)
setProductIdPrice(productId: productIdAnnualPro, price: "\(product.localizedPrice!)")
setProductIdUpgradePrice(productId: productIdAnnualPro, upgradePrice: "\(product.localizedPrice!)")
}
else {
DDLogError("unable to format price with currencyformatter: " + product.price.stringValue)
setProductIdPrice(productId: productIdAnnualPro, price: defaultPriceStringAnnualPro)
setProductIdUpgradePrice(productId: productIdAnnualPro, upgradePrice: defaultUpgradePriceStringAnnualPro)
setProductIdPriceAnnualMonthly(productId: productIdAnnualPro, price: defaultPriceSubStringAnnualPro)
}
}
setTrialDuration(productId: product.productIdentifier, duration: trialDuraion(for: product.introductoryPrice))
}
for invalidProductId in result.invalidProductIDs {
DDLogError("invalid product id: \(invalidProductId)");
}
}
}
private static func trialDuraion(for trial: SKProductDiscount?) -> String? {
guard let trial,
trial.paymentMode == .freeTrial else {
return nil
}
let unit = switch trial.subscriptionPeriod.unit {
case .day: NSLocalizedString("day", comment: "day")
case .week: NSLocalizedString("week", comment: "week")
case .month: NSLocalizedString("month", comment: "month")
case .year: NSLocalizedString("year", comment: "year")
}
return "\(trial.subscriptionPeriod.numberOfUnits)" + "-" + unit
}
}
extension Subscription.PlanType {
var productId: String? {
switch self {
case .advancedMonthly:
return VPNSubscription.productIdAdvancedMonthly
case .advancedAnnual:
return VPNSubscription.productIdAdvancedYearly
case .anonymousMonthly:
return VPNSubscription.productIdMonthly
case .anonymousAnnual:
return VPNSubscription.productIdAnnual
case .universalMonthly:
return VPNSubscription.productIdMonthlyPro
case .universalAnnual:
return VPNSubscription.productIdAnnualPro
default:
return nil
}
}
static var supported: [Subscription.PlanType] {
return [.advancedMonthly, .advancedAnnual, .anonymousMonthly, .anonymousAnnual, .universalMonthly, .universalAnnual]
}
var availableUpgrades: [Subscription.PlanType]? {
switch self {
case .advancedMonthly:
return [.advancedAnnual, .anonymousMonthly, .anonymousAnnual, .universalMonthly, .universalAnnual]
case .advancedAnnual:
return [.anonymousMonthly, .anonymousAnnual, .universalMonthly, .universalAnnual]
case .anonymousMonthly:
return [.anonymousAnnual, .universalMonthly, .universalAnnual]
case .anonymousAnnual:
return [.universalMonthly, .universalAnnual]
case .universalMonthly:
return [.universalAnnual]
case .universalAnnual:
return []
default:
return nil
}
}
var unavailableToUpgrade: [Subscription.PlanType]? {
guard let upgrades = availableUpgrades else {
return nil
}
var candidates = Subscription.PlanType.supported
candidates.removeAll(where: { upgrades.contains($0) })
return candidates
}
func canUpgrade(to newPlan: Subscription.PlanType) -> Bool {
return availableUpgrades?.contains(newPlan) == true
}
}