diff --git a/IVPNClient.xcodeproj/project.pbxproj b/IVPNClient.xcodeproj/project.pbxproj index ea0624938..56bdd201b 100644 --- a/IVPNClient.xcodeproj/project.pbxproj +++ b/IVPNClient.xcodeproj/project.pbxproj @@ -60,6 +60,7 @@ 8223C54F22EAEC7000CD283D /* Session.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8223C54B22E9E93A00CD283D /* Session.swift */; }; 8223C55022EAEC7100CD283D /* Session.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8223C54B22E9E93A00CD283D /* Session.swift */; }; 822563922431E03A00AE7F8D /* AccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 822563912431E03A00AE7F8D /* AccountView.swift */; }; + 8228C8D22B1DE906005977D3 /* PurchaseManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8228C8D12B1DE906005977D3 /* PurchaseManager.swift */; }; 8229196E2182EB1C00978BBA /* String+Ext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8229196D2182EB1B00978BBA /* String+Ext.swift */; }; 822919712182EB1C00978BBA /* String+Ext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8229196D2182EB1B00978BBA /* String+Ext.swift */; }; 822920A02480FA3600476FC1 /* ServersSort.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8229209F2480FA3600476FC1 /* ServersSort.swift */; }; @@ -93,8 +94,7 @@ 824777EA21A6BC3A001EEFAF /* Network+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 824777E621A6BC3A001EEFAF /* Network+CoreDataProperties.swift */; }; 8247A5ED215D037600E8D680 /* UserDefaults+Ext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 825A43FC215CCFE70076131F /* UserDefaults+Ext.swift */; }; 8247C0602A7CF54300A7C02F /* V2RayConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8247C05F2A7CF54300A7C02F /* V2RayConfig.swift */; }; - 8247E1DA22686217006C0C08 /* IAPManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8247E1D922686217006C0C08 /* IAPManager.swift */; }; - 8247E1DE22687C28006C0C08 /* ProductIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8247E1DD22687C28006C0C08 /* ProductIdentifier.swift */; }; + 8247E1DE22687C28006C0C08 /* ProductId.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8247E1DD22687C28006C0C08 /* ProductId.swift */; }; 82486FAD2A277058009B53F4 /* liboqs.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 82486FAC2A277058009B53F4 /* liboqs.a */; }; 824B141C2609D5E700766B05 /* DNSProtocolTypeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 824B141B2609D5E700766B05 /* DNSProtocolTypeTests.swift */; }; 824B86B926D3D16100D0101A /* FileManager+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 824B86AD26D3D16100D0101A /* FileManager+Extension.swift */; }; @@ -108,6 +108,7 @@ 824BC466240906ED00A61B29 /* VPNStatusViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 824BC465240906ED00A61B29 /* VPNStatusViewModel.swift */; }; 82526BEF24123D2900E00880 /* NetworkViewTableCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82526BEE24123D2900E00880 /* NetworkViewTableCell.swift */; }; 8252747E21F1F80400D4B8B5 /* ServerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8252747D21F1F80400D4B8B5 /* ServerViewController.swift */; }; + 825443982B2A1B8F00D77095 /* Store.storekit in Resources */ = {isa = PBXBuildFile; fileRef = 825443972B2A1B8F00D77095 /* Store.storekit */; }; 82555005220ACAAF004763A7 /* VPNServersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82555004220ACAAF004763A7 /* VPNServersTests.swift */; }; 82589A2B21FB5A580009CC6C /* UIImage+Ext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82589A2A21FB5A580009CC6C /* UIImage+Ext.swift */; }; 825E834F25A327EB00938240 /* CaptchaViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 825E834E25A327EB00938240 /* CaptchaViewController.swift */; }; @@ -498,6 +499,7 @@ 8223C54B22E9E93A00CD283D /* Session.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Session.swift; sourceTree = ""; }; 8223C54D22EAE93F00CD283D /* SessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionManager.swift; sourceTree = ""; }; 822563912431E03A00AE7F8D /* AccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountView.swift; sourceTree = ""; }; + 8228C8D12B1DE906005977D3 /* PurchaseManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchaseManager.swift; sourceTree = ""; }; 8229196D2182EB1B00978BBA /* String+Ext.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+Ext.swift"; sourceTree = ""; }; 8229209F2480FA3600476FC1 /* ServersSort.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServersSort.swift; sourceTree = ""; }; 822B85D821B941A200715691 /* NotificationName+Ext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationName+Ext.swift"; sourceTree = ""; }; @@ -523,8 +525,7 @@ 824777E521A6BC3A001EEFAF /* Network+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Network+CoreDataClass.swift"; sourceTree = ""; }; 824777E621A6BC3A001EEFAF /* Network+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Network+CoreDataProperties.swift"; sourceTree = ""; }; 8247C05F2A7CF54300A7C02F /* V2RayConfig.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = V2RayConfig.swift; sourceTree = ""; }; - 8247E1D922686217006C0C08 /* IAPManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IAPManager.swift; sourceTree = ""; }; - 8247E1DD22687C28006C0C08 /* ProductIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductIdentifier.swift; sourceTree = ""; }; + 8247E1DD22687C28006C0C08 /* ProductId.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductId.swift; sourceTree = ""; }; 82486FAC2A277058009B53F4 /* liboqs.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = liboqs.a; sourceTree = ""; }; 82486FB02A27705F009B53F4 /* sha3x4.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = sha3x4.h; sourceTree = ""; }; 82486FB12A27705F009B53F4 /* oqsconfig.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = oqsconfig.h; sourceTree = ""; }; @@ -547,6 +548,7 @@ 824F56072233FE6F00BCDD5C /* libwg-go.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libwg-go.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 82526BEE24123D2900E00880 /* NetworkViewTableCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkViewTableCell.swift; sourceTree = ""; }; 8252747D21F1F80400D4B8B5 /* ServerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerViewController.swift; sourceTree = ""; }; + 825443972B2A1B8F00D77095 /* Store.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = Store.storekit; sourceTree = ""; }; 82555004220ACAAF004763A7 /* VPNServersTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNServersTests.swift; sourceTree = ""; }; 8258649C2237A0830081DC4B /* SDCAlertView.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SDCAlertView.framework; path = ../Carthage/Build/iOS/SDCAlertView.framework; sourceTree = ""; }; 825864A02237B1060081DC4B /* Bamboo.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Bamboo.framework; path = ../Carthage/Build/iOS/Bamboo.framework; sourceTree = ""; }; @@ -1000,12 +1002,12 @@ 82D598BF21A6991B000FABDE /* NetworkManager.swift */, 9CB2CE371DB0F860007A4D2D /* FileSystemManager.swift */, 9C69422D1DD20FC200F9A801 /* KeyChain.swift */, - 8247E1D922686217006C0C08 /* IAPManager.swift */, 820535272302B9D7007BDD58 /* APIAccessManager.swift */, 8223C54D22EAE93F00CD283D /* SessionManager.swift */, 8206F32224367A240056B465 /* VPNErrorObserver.swift */, 825E836225A4834200938240 /* APIPublicKeyPin.swift */, 826C1F8325DBEF1800314C4B /* DNSManager.swift */, + 8228C8D12B1DE906005977D3 /* PurchaseManager.swift */, ); path = Managers; sourceTree = ""; @@ -1021,7 +1023,7 @@ 82C9739F217DFA9C00CE06D4 /* Host.swift */, 9C6942361DD218A900F9A801 /* AccessDetails.swift */, 9C3031341DB42EF900C38B0C /* Application.swift */, - 8247E1DD22687C28006C0C08 /* ProductIdentifier.swift */, + 8247E1DD22687C28006C0C08 /* ProductId.swift */, 9CB2CE1E1DAA5258007A4D2D /* Authentication.swift */, 9CBFF02F2102254800FE1757 /* Settings.swift */, 826C56D122FD4F2600D2B76A /* ServiceStatus.swift */, @@ -1654,6 +1656,7 @@ 82FF0D4123153D1000440E5D /* Colors.xcassets */, 9CB2CE261DAA6C1B007A4D2D /* IVPNClient.entitlements */, 9CB2CE321DAF9283007A4D2D /* Model.xcdatamodeld */, + 825443972B2A1B8F00D77095 /* Store.storekit */, ); path = IVPNClient; sourceTree = ""; @@ -1993,6 +1996,7 @@ 9CDDD5B41D9D2F9F00D39924 /* Main.storyboard in Resources */, 9C2833741D9D3EB60024C553 /* Initial.storyboard in Resources */, 826470C42446F67100403A14 /* Signup.storyboard in Resources */, + 825443982B2A1B8F00D77095 /* Store.storekit in Resources */, 82FF0D4223153D1000440E5D /* Colors.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -2271,7 +2275,6 @@ 8243584425DAA7BD005FDEBB /* SecureDNSViewController.swift in Sources */, 82BBF26D21AE95C000589766 /* UIApplication+Ext.swift in Sources */, 82B329CB29F7C9F400F3ED9B /* UIWindow+Ext.swift in Sources */, - 8247E1DA22686217006C0C08 /* IAPManager.swift in Sources */, 82C973A0217DFA9C00CE06D4 /* Host.swift in Sources */, 82A160BA221C4E2000730577 /* Server+CoreDataClass.swift in Sources */, 9CB2CE381DB0F860007A4D2D /* FileSystemManager.swift in Sources */, @@ -2357,6 +2360,7 @@ 8206E5D022967E37003119AF /* UserActivityType.swift in Sources */, 82A6D74A24A3780B00D6C0E1 /* ConnectToServerPopupView.swift in Sources */, 828772FB221C28E000D5E330 /* FlagImageView.swift in Sources */, + 8228C8D22B1DE906005977D3 /* PurchaseManager.swift in Sources */, 82E7880C22B0DA0D00A98D76 /* NETunnelProviderProtocol+Ext.swift in Sources */, 82968A35298A98C300077E0A /* KeyChain.swift in Sources */, 82F638C2217DA89000410318 /* AddressType.swift in Sources */, @@ -2375,7 +2379,7 @@ 821CA2D7287C5AB20067F70D /* PortViewController.swift in Sources */, 82061F66238D2730009DDF4D /* ICMPHeader.swift in Sources */, 824BC466240906ED00A61B29 /* VPNStatusViewModel.swift in Sources */, - 8247E1DE22687C28006C0C08 /* ProductIdentifier.swift in Sources */, + 8247E1DE22687C28006C0C08 /* ProductId.swift in Sources */, 829DF2822497953C000DC2DB /* UIButton+Ext.swift in Sources */, 82234B6721BA7F3500B082DE /* Logger.swift in Sources */, 82DB75EC239E75EB0073E846 /* NEVPNStatus+Ext.swift in Sources */, diff --git a/IVPNClient.xcodeproj/xcshareddata/xcschemes/IVPNClient.xcscheme b/IVPNClient.xcodeproj/xcshareddata/xcschemes/IVPNClient.xcscheme index b86e17588..2d24c5fc7 100644 --- a/IVPNClient.xcodeproj/xcshareddata/xcschemes/IVPNClient.xcscheme +++ b/IVPNClient.xcodeproj/xcshareddata/xcschemes/IVPNClient.xcscheme @@ -109,6 +109,9 @@ isEnabled = "YES"> + + Bool { evaluateUITests() registerUserDefaults() - finishIncompletePurchases() createLogFiles() resetLastPingTimestamp() clearURLCache() + startPurchaseObserver() DNSManager.shared.loadProfile { _ in } return true @@ -402,3 +393,49 @@ extension AppDelegate: UIApplicationDelegate { } } + +// MARK: - PurchaseManagerDelegate - + +extension AppDelegate: PurchaseManagerDelegate { + + func purchaseStart() { + + } + + func purchasePending() { + DispatchQueue.main.async { + guard let viewController = UIApplication.topViewController() else { + return + } + + viewController.showAlert(title: "Pending payment", message: "Payment is pending for approval. We will complete the transaction as soon as payment is approved.") + } + } + + func purchaseSuccess(activeUntil: String, extended: Bool) { + guard extended else { + return + } + + DispatchQueue.main.async { + guard let viewController = UIApplication.topViewController() else { + return + } + + viewController.showSubscriptionActivatedAlert(activeUntil: activeUntil) + } + } + + func purchaseError(error: Any?) { + DispatchQueue.main.async { + guard let viewController = UIApplication.topViewController() else { + return + } + + if let error = error as? ErrorResult { + viewController.showErrorAlert(title: "Error", message: error.message) + } + } + } + +} diff --git a/IVPNClient/Config/Config.swift b/IVPNClient/Config/Config.swift index 8941be8af..df789cff6 100644 --- a/IVPNClient/Config/Config.swift +++ b/IVPNClient/Config/Config.swift @@ -37,10 +37,10 @@ struct Config { static let apiSessionDelete = "/v4/session/delete" static let apiSessionWGKeySet = "/v4/session/wg/set" static let apiAccountNew = "/v4/account/new" - static let apiPaymentInitial = "/v4/account/payment/ios/initial" - static let apiPaymentAdd = "/v4/account/payment/ios/add" - static let apiPaymentAddLegacy = "/v2/mobile/ios/subscription-purchased" - static let apiPaymentRestore = "/v4/account/payment/ios/restore" + static let apiPaymentInitial = "/v5/account/payment/ios/initial" + static let apiPaymentAdd = "/v5/account/payment/ios/add" + static let apiPaymentRestore = "/v5/account/payment/ios/restore" + static let apiPaymentAddLegacy = "/v2/mobile/ios/subscription-purchased-v2" static let urlTypeLogin = "login" static let urlTypeConnect = "connect" diff --git a/IVPNClient/Enums/ApiResults/SessionStatus.swift b/IVPNClient/Enums/ApiResults/SessionStatus.swift index 6af241eca..37a1057ae 100644 --- a/IVPNClient/Enums/ApiResults/SessionStatus.swift +++ b/IVPNClient/Enums/ApiResults/SessionStatus.swift @@ -26,6 +26,7 @@ import Foundation struct SessionStatus: Decodable { let status: Int let deviceName: String? + let extended: Bool? let serviceStatus: ServiceStatus var serviceActive: Bool { diff --git a/IVPNClient/Managers/IAPManager.swift b/IVPNClient/Managers/IAPManager.swift deleted file mode 100644 index ce9225166..000000000 --- a/IVPNClient/Managers/IAPManager.swift +++ /dev/null @@ -1,292 +0,0 @@ -// -// IAPManager.swift -// IVPN iOS app -// https://github.com/ivpn/ios-app -// -// Created by Juraj Hilje on 2019-04-18. -// Copyright (c) 2023 IVPN Limited. -// -// This file is part of the IVPN iOS app. -// -// The IVPN iOS app is free software: you can redistribute it and/or -// modify it under the terms of the GNU General Public License as published by the Free -// Software Foundation, either version 3 of the License, or (at your option) any later version. -// -// The IVPN iOS app is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY -// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -// details. -// -// You should have received a copy of the GNU General Public License -// along with the IVPN iOS app. If not, see . -// - -import StoreKit -import SwiftyStoreKit - -class IAPManager { - - // MARK: - Properties - - - static let shared = IAPManager() - var products: [SKProduct] = [] - - var canMakePurchases: Bool { - return SKPaymentQueue.canMakePayments() - } - - private var apiEndpoint: String { - if KeyChain.sessionToken != nil { - if !Application.shared.serviceStatus.isNewStyleAccount() { - return Config.apiPaymentAddLegacy - } - - return Config.apiPaymentAdd - } - - return Config.apiPaymentInitial - } - - // MARK: - Methods - - - func fetchProducts(completion: @escaping ([SKProduct]?, String?) -> Void) { - SwiftyStoreKit.retrieveProductsInfo(ProductIdentifier.all) { result in - if !result.retrievedProducts.isEmpty { - self.products = Array(result.retrievedProducts) - completion(Array(result.retrievedProducts), nil) - log(.info, message: "Products successfully fetched from App Store.") - } else if !result.invalidProductIDs.isEmpty { - completion(nil, "Invalid product identifier") - log(.info, message: "Invalid App Store product identifier.") - } else { - completion(nil, String(describing: result.error)) - log(.info, message: "There was an error with fetching products from App Store.") - } - } - } - - func purchaseProduct(identifier: String, completion: @escaping (PurchaseDetails?, String?) -> Void) { - SwiftyStoreKit.purchaseProduct(identifier, quantity: 1, atomically: false) { result in - switch result { - case .success(let purchase): - completion(purchase, nil) - log(.info, message: "Product was successfully purchased.") - case .error(let error): - switch error.code { - case .unknown: completion(nil, "Unknown error. Please contact support") - case .clientInvalid: completion(nil, "Not allowed to make the payment") - case .paymentCancelled: completion(nil, "Payment cancelled") - case .paymentInvalid: completion(nil, "The purchase identifier was invalid") - case .paymentNotAllowed: completion(nil, "The device is not allowed to make the payment") - case .storeProductNotAvailable: completion(nil, "The product is not available in the current storefront") - case .cloudServicePermissionDenied: completion(nil, "Access to cloud service information is not allowed") - case .cloudServiceNetworkConnectionFailed: completion(nil, "Could not connect to the network") - case .cloudServiceRevoked: completion(nil, "User has revoked permission to use this cloud service") - default: completion(nil, (error as NSError).localizedDescription) - } - log(.error, message: "There was an error with purchase.") - } - } - } - - func finishIncompletePurchases(completion: @escaping (ServiceStatus?, ErrorResult?) -> Void) { - SwiftyStoreKit.completeTransactions(atomically: false) { products in - self.completePurchases(products: products, endpoint: self.apiEndpoint) { serviceStatus, error in - completion(serviceStatus, error) - } - } - } - - func restorePurchases(completion: @escaping (Account?, ErrorResult?) -> Void) { - SwiftyStoreKit.restorePurchases(atomically: false) { results in - if results.restoreFailedPurchases.count > 0 { - if let restoreError = results.restoreFailedPurchases.first { - let error = ErrorResult(status: 500, message: restoreError.0.localizedDescription) - completion(nil, error) - log(.error, message: restoreError.0.localizedDescription) - return - } - - let error = ErrorResult(status: 500, message: "Unknown error") - completion(nil, error) - log(.error, message: "Unknown error") - } else if results.restoredPurchases.count > 0 { - var purchases = results.restoredPurchases - purchases.sort { $0.transaction.transactionDate! > $1.transaction.transactionDate! } - self.completeRestoredPurchase(purchase: purchases.first!) { account, error in - completion(account, error) - log(.info, message: "Purchases are restored.") - } - } else { - let error = ErrorResult(status: 500, message: "There are no purchases to restore.") - completion(nil, error) - log(.error, message: "There are no purchases to restore.") - } - } - } - - func completePurchase(purchase: PurchaseDetails, completion: @escaping (ServiceStatus?, ErrorResult?) -> Void) { - let endpoint = apiEndpoint - let params = purchaseParams(purchase: purchase, endpoint: endpoint) - let request = ApiRequestDI(method: .post, endpoint: endpoint, params: params) - - ApiService.shared.requestCustomError(request) { (result: ResultCustomError) in - switch result { - case .success(let sessionStatus): - if purchase.needsFinishTransaction { - SwiftyStoreKit.finishTransaction(purchase.transaction) - } - Application.shared.serviceStatus = sessionStatus.serviceStatus - completion(sessionStatus.serviceStatus, nil) - log(.info, message: "Purchase was successfully finished.") - case .failure(let error): - let defaultErrorResult = ErrorResult(status: 500, message: "Purchase was completed but service cannot be activated. Restart application to retry.") - completion(nil, error ?? defaultErrorResult) - log(.error, message: "There was an error with purchase completion: \(error?.message ?? "")") - } - } - } - - func completePurchases(products: [Purchase], endpoint: String, completion: @escaping (ServiceStatus?, ErrorResult?) -> Void) { - if let product = products.last { - log(.info, message: "Found incomplete purchase. Completing purchase...") - - switch product.transaction.transactionState { - case .purchased, .restored: - if product.needsFinishTransaction { - let params = finishPurchaseParams(product: product, endpoint: endpoint) - let request = ApiRequestDI(method: .post, endpoint: endpoint, params: params) - - ApiService.shared.requestCustomError(request) { (result: ResultCustomError) in - switch result { - case .success(let sessionStatus): - SwiftyStoreKit.finishTransaction(product.transaction) - Application.shared.serviceStatus = sessionStatus.serviceStatus - completion(sessionStatus.serviceStatus, nil) - log(.info, message: "Purchase was successfully finished.") - case .failure(let error): - let defaultErrorResult = ErrorResult(status: 500, message: "Purchase was completed but service cannot be activated. Restart application to retry.") - completion(nil, error ?? defaultErrorResult) - log(.error, message: "There was an error with purchase completion: \(error?.message ?? "")") - } - } - } - case .failed, .purchasing, .deferred: - break - @unknown default: - break - } - } - } - - func completeRestoredPurchase(purchase: Purchase, completion: @escaping (Account?, ErrorResult?) -> Void) { - let params = restorePurchaseParams() - let request = ApiRequestDI(method: .post, endpoint: Config.apiPaymentRestore, params: params) - - ApiService.shared.requestCustomError(request) { (result: ResultCustomError) in - switch result { - case .success(let account): - if purchase.needsFinishTransaction { - SwiftyStoreKit.finishTransaction(purchase.transaction) - } - KeyChain.username = account.accountId - completion(account, nil) - log(.info, message: "Purchase was successfully finished.") - case .failure(let error): - let defaultErrorResult = ErrorResult(status: 500, message: "Purchase was restored but service cannot be activated. Restart application to retry.") - completion(nil, error ?? defaultErrorResult) - log(.error, message: "There was an error with purchase completion: \(error?.message ?? "")") - } - } - } - - func getProduct(identifier: String) -> SKProduct? { - for product in products where product.productIdentifier == identifier { - return product - } - - return nil - } - - func productPrice(product: SKProduct) -> String { - let formatter = NumberFormatter() - formatter.numberStyle = .currency - formatter.locale = product.priceLocale - - return formatter.string(from: product.price) ?? "" - } - - func completeTransactions() { - SwiftyStoreKit.completeTransactions(atomically: true) { _ in } - } - - // MARK: - Private methods - - - private func purchaseParams(purchase: PurchaseDetails, endpoint: String) -> [URLQueryItem] { - let transactionId = purchase.transaction.transactionIdentifier ?? "Unknown transaction ID" - let base64receipt = SwiftyStoreKit.localReceiptData?.base64EncodedString(options: []) ?? "" - - switch endpoint { - case Config.apiPaymentInitial: - return [ - URLQueryItem(name: "account_id", value: KeyChain.tempUsername ?? ""), - URLQueryItem(name: "product_id", value: purchase.product.productIdentifier), - URLQueryItem(name: "transaction_id", value: transactionId), - URLQueryItem(name: "receipt", value: base64receipt) - ] - case Config.apiPaymentAdd: - return [ - URLQueryItem(name: "session_token", value: KeyChain.sessionToken ?? ""), - URLQueryItem(name: "product_id", value: purchase.product.productIdentifier), - URLQueryItem(name: "transaction_id", value: transactionId), - URLQueryItem(name: "receipt", value: base64receipt) - ] - case Config.apiPaymentAddLegacy: - return [ - URLQueryItem(name: "username", value: KeyChain.username ?? ""), - URLQueryItem(name: "productId", value: purchase.product.productIdentifier), - URLQueryItem(name: "transactionId", value: transactionId), - URLQueryItem(name: "receiptData", value: base64receipt) - ] - default: - return [] - } - } - - private func finishPurchaseParams(product: Purchase, endpoint: String) -> [URLQueryItem] { - let transactionId = product.transaction.transactionIdentifier ?? "" - let base64receipt = SwiftyStoreKit.localReceiptData?.base64EncodedString(options: []) ?? "" - - switch endpoint { - case Config.apiPaymentInitial: - return [ - URLQueryItem(name: "account_id", value: KeyChain.tempUsername ?? ""), - URLQueryItem(name: "product_id", value: product.productId), - URLQueryItem(name: "transaction_id", value: transactionId), - URLQueryItem(name: "receipt", value: base64receipt) - ] - case Config.apiPaymentAdd: - return [ - URLQueryItem(name: "session_token", value: KeyChain.sessionToken ?? ""), - URLQueryItem(name: "product_id", value: product.productId), - URLQueryItem(name: "transaction_id", value: transactionId), - URLQueryItem(name: "receipt", value: base64receipt) - ] - case Config.apiPaymentAddLegacy: - return [ - URLQueryItem(name: "username", value: KeyChain.username ?? ""), - URLQueryItem(name: "productId", value: product.productId), - URLQueryItem(name: "transactionId", value: transactionId), - URLQueryItem(name: "receiptData", value: base64receipt) - ] - default: - return [] - } - } - - private func restorePurchaseParams() -> [URLQueryItem] { - let base64receipt = SwiftyStoreKit.localReceiptData?.base64EncodedString(options: []) ?? "" - return [URLQueryItem(name: "receipt", value: base64receipt)] - } - -} diff --git a/IVPNClient/Managers/PurchaseManager.swift b/IVPNClient/Managers/PurchaseManager.swift new file mode 100644 index 000000000..f0fe61c43 --- /dev/null +++ b/IVPNClient/Managers/PurchaseManager.swift @@ -0,0 +1,246 @@ +// +// PurchaseManager.swift +// IVPN iOS app +// https://github.com/ivpn/ios-app +// +// Created by Juraj Hilje on 2023-12-04. +// Copyright (c) 2023 IVPN Limited. +// +// This file is part of the IVPN iOS app. +// +// The IVPN iOS app is free software: you can redistribute it and/or +// modify it under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) any later version. +// +// The IVPN iOS app is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +// details. +// +// You should have received a copy of the GNU General Public License +// along with the IVPN iOS app. If not, see . +// + +import StoreKit + +@objc protocol PurchaseManagerDelegate: AnyObject { + func purchaseStart() + func purchasePending() + func purchaseSuccess(activeUntil: String, extended: Bool) + func purchaseError(error: Any?) +} + +class PurchaseManager: NSObject { + + // MARK: - Properties - + + static let shared = PurchaseManager() + + weak var delegate: PurchaseManagerDelegate? + + var canMakePurchases: Bool { + return SKPaymentQueue.canMakePayments() + } + + var observerTask: Task? = nil + + private(set) var products: [Product] = [] + + private var apiEndpoint: String { + guard let _ = KeyChain.sessionToken else { + return Config.apiPaymentInitial + } + + return Application.shared.serviceStatus.isNewStyleAccount() ? Config.apiPaymentAdd : Config.apiPaymentAddLegacy + } + + deinit { + stopObserver() + } + + // MARK: - Methods - + + func loadProducts() async throws { + products = try await Product.products(for: ProductId.all) + } + + func getProduct(id: String) -> Product? { + for product in products where product.id == id { + return product + } + + return nil + } + + func purchase(_ productId: String) async throws -> Product.PurchaseResult? { + guard let product = getProduct(id: productId) else { + return nil + } + + delegate?.purchaseStart() + let result = try await product.purchase() + + switch result { + case let .success(.verified(transaction)): + // Successful purchase + log(.info, message: "[Store] Completing successful in-app purchase \(productId)") + self.complete(transaction) + break + case .success(.unverified(_, _)): + // Successful purchase but transaction/receipt can't be verified + // Could be a jailbroken phone + log(.info, message: "[Store] Purchase \(productId): success, unverified") + delegate?.purchaseError(error: ErrorResult(status: 500, message: "Purchase is unverified.")) + break + case .pending: + // Transaction waiting on SCA (Strong Customer Authentication) or + // approval from Ask to Buy + log(.info, message: "[Store] Purchase \(productId): pending") + delegate?.purchasePending() + break + case .userCancelled: + // ^^^ + log(.info, message: "[Store] Purchase \(productId): userCancelled") + delegate?.purchaseError(error: ErrorResult(status: 500, message: "User canelled the purchase.")) + break + @unknown default: + break + } + + return result + } + + func startObserver() { + observerTask = Task(priority: .background) { + for await result in Transaction.updates { + guard case .verified(let transaction) = result else { + continue + } + + if transaction.revocationDate == nil { + log(.info, message: "[Store] Completing unfinished purchase \(transaction.productID)") + complete(transaction) + } + } + } + } + + func stopObserver() { + observerTask?.cancel() + } + + func restorePurchases(completion: @escaping (Account?, ErrorResult?) -> Void) { + Task { + for await result in Transaction.currentEntitlements { + guard case .verified(let transaction) = result else { + continue + } + + if transaction.revocationDate == nil { + self.getAccountFor(transaction: transaction) { account, error in + completion(account, error) + } + return + } + } + + let error = ErrorResult(status: 500, message: "There are no purchases to restore.") + log(.error, message: "[Store] There are no purchases to restore") + completion(nil, error) + } + } + + func complete(_ transaction: Transaction) { + let defaultError = ErrorResult(status: 500, message: "Purchase was completed but service cannot be activated. Restart application to retry.") + let endpoint = apiEndpoint + + guard let params = purchaseParams(transaction: transaction, endpoint: endpoint) else { + delegate?.purchaseError(error: defaultError) + return + } + + let request = ApiRequestDI(method: .post, endpoint: endpoint, params: params) + + ApiService.shared.requestCustomError(request) { (result: ResultCustomError) in + switch result { + case .success(let sessionStatus): + Application.shared.serviceStatus = sessionStatus.serviceStatus + Task { + await transaction.finish() + self.delegate?.purchaseSuccess(activeUntil: sessionStatus.serviceStatus.activeUntilString(), extended: sessionStatus.extended ?? !sessionStatus.serviceStatus.isNewStyleAccount()) + log(.info, message: "[Store] Purchase \(transaction.productID) completed successfully") + } + case .failure(let error): + self.delegate?.purchaseError(error: error ?? defaultError) + log(.error, message: "[Store] There was an error with purchase completion: \(error?.message ?? "")") + } + } + } + + // MARK: - Private methods - + + private func getAccountFor(transaction: Transaction, completion: @escaping (Account?, ErrorResult?) -> Void) { + let defaultError = ErrorResult(status: 500, message: "Purchase was restored but service cannot be activated. Restart application to retry.") + guard let params = restorePurchaseParams(transaction) else { + completion(nil, defaultError) + return + } + + let request = ApiRequestDI(method: .post, endpoint: Config.apiPaymentRestore, params: params) + + ApiService.shared.requestCustomError(request) { (result: ResultCustomError) in + switch result { + case .success(let account): + KeyChain.username = account.accountId + completion(account, nil) + log(.info, message: "[Store] Purchase restored successfully") + case .failure(let error): + completion(nil, error ?? defaultError) + log(.error, message: "[Store] There was an error with restoring purchase: \(error?.message ?? "")") + } + } + } + + private func purchaseParams(transaction: Transaction, endpoint: String) -> [URLQueryItem]? { + let productId = transaction.productID + let transactionId = String(transaction.id) + + switch endpoint { + case Config.apiPaymentInitial: + guard let tempUsername = KeyChain.tempUsername else { + return nil + } + return [ + URLQueryItem(name: "account_id", value: tempUsername), + URLQueryItem(name: "product_id", value: productId), + URLQueryItem(name: "transaction_id", value: transactionId) + ] + case Config.apiPaymentAdd: + guard let sessionToken = KeyChain.sessionToken else { + return nil + } + return [ + URLQueryItem(name: "session_token", value: sessionToken), + URLQueryItem(name: "product_id", value: productId), + URLQueryItem(name: "transaction_id", value: transactionId) + ] + case Config.apiPaymentAddLegacy: + guard let username = KeyChain.username else { + return nil + } + return [ + URLQueryItem(name: "username", value: username), + URLQueryItem(name: "productId", value: productId), + URLQueryItem(name: "transactionId", value: transactionId) + ] + default: + return nil + } + } + + private func restorePurchaseParams(_ transaction: Transaction) -> [URLQueryItem]? { + let transactionId = String(transaction.id) + return [URLQueryItem(name: "transaction_id", value: transactionId)] + } + +} diff --git a/IVPNClient/Models/ProductIdentifier.swift b/IVPNClient/Models/ProductId.swift similarity index 90% rename from IVPNClient/Models/ProductIdentifier.swift rename to IVPNClient/Models/ProductId.swift index 730185db6..f7e38871a 100644 --- a/IVPNClient/Models/ProductIdentifier.swift +++ b/IVPNClient/Models/ProductId.swift @@ -1,13 +1,5 @@ // -// ProductIdentifier.swift -// IVPNClient -// -// Created by Juraj Hilje on 18/04/2019. -// Copyright © 2019 IVPN. All rights reserved. -// - -// -// ProductIdentifier.swift +// ProductId.swift // IVPN iOS app // https://github.com/ivpn/ios-app // @@ -31,7 +23,7 @@ import Foundation -struct ProductIdentifier { +struct ProductId { static let standardWeek = "net.ivpn.subscriptions.standard.1week" static let standardMonth = "net.ivpn.subscriptions.standard.1month" diff --git a/IVPNClient/Models/Service.swift b/IVPNClient/Models/Service.swift index 347361fd8..1bdd91635 100644 --- a/IVPNClient/Models/Service.swift +++ b/IVPNClient/Models/Service.swift @@ -33,9 +33,11 @@ struct Service { // MARK: - Computed properties - var priceText: String { - guard !IAPManager.shared.products.isEmpty else { return "" } - guard let product = IAPManager.shared.getProduct(identifier: productId) else { return "" } - return IAPManager.shared.productPrice(product: product) + guard let product = PurchaseManager.shared.getProduct(id: productId) else { + return "" + } + + return product.displayPrice } var durationText: String { @@ -89,28 +91,28 @@ struct Service { case .standard: switch duration { case .week: - return ProductIdentifier.standardWeek + return ProductId.standardWeek case .month: - return ProductIdentifier.standardMonth + return ProductId.standardMonth case .year: - return ProductIdentifier.standardYear + return ProductId.standardYear case .twoYears: - return ProductIdentifier.standardTwoYears + return ProductId.standardTwoYears case .threeYears: - return ProductIdentifier.standardThreeYears + return ProductId.standardThreeYears } case .pro: switch duration { case .week: - return ProductIdentifier.proWeek + return ProductId.proWeek case .month: - return ProductIdentifier.proMonth + return ProductId.proMonth case .year: - return ProductIdentifier.proYear + return ProductId.proYear case .twoYears: - return ProductIdentifier.proTwoYears + return ProductId.proTwoYears case .threeYears: - return ProductIdentifier.proThreeYears + return ProductId.proThreeYears } } } diff --git a/IVPNClient/Scenes/Signup/LoginViewController.swift b/IVPNClient/Scenes/Signup/LoginViewController.swift index 80c75fb43..c0df4a380 100644 --- a/IVPNClient/Scenes/Signup/LoginViewController.swift +++ b/IVPNClient/Scenes/Signup/LoginViewController.swift @@ -104,17 +104,19 @@ class LoginViewController: UIViewController { hud.detailTextLabel.text = "Restoring purchases..." hud.show(in: (navigationController?.view)!) - IAPManager.shared.restorePurchases { account, error in - self.hud.dismiss() - - if let error = error { - self.showErrorAlert(title: "Restore failed", message: error.message) - return - } - - if account != nil { - self.userName.text = account?.accountId - self.sessionManager.createSession() + PurchaseManager.shared.restorePurchases { account, error in + DispatchQueue.main.async { + self.hud.dismiss() + + if let error = error { + self.showErrorAlert(title: "Restore failed", message: error.message) + return + } + + if let account = account { + self.userName.text = account.accountId + self.sessionManager.createSession() + } } } } diff --git a/IVPNClient/Scenes/Signup/Payment/PaymentViewController.swift b/IVPNClient/Scenes/Signup/Payment/PaymentViewController.swift index ca64c8302..396f0dd8f 100644 --- a/IVPNClient/Scenes/Signup/Payment/PaymentViewController.swift +++ b/IVPNClient/Scenes/Signup/Payment/PaymentViewController.swift @@ -22,7 +22,7 @@ // import UIKit -import SwiftyStoreKit +import StoreKit import SnapKit import JGProgressHUD @@ -54,7 +54,7 @@ class PaymentViewController: UITableViewController { lazy var retryButton: UIButton = { let button = UIButton(type: .system) - button.addTarget(self, action: #selector(fetchProducts), for: .touchUpInside) + button.addTarget(self, action: #selector(load), for: .touchUpInside) button.setTitle("Retry", for: .normal) button.sizeToFit() button.isHidden = true @@ -101,7 +101,9 @@ class PaymentViewController: UITableViewController { } @IBAction func purchase(_ sender: UIButton) { - purchaseProduct(identifier: service.productId) + Task { + await purchaseProduct(identifier: service.productId) + } } @IBAction func close() { @@ -119,16 +121,24 @@ class PaymentViewController: UITableViewController { setupView() } + override func viewDidDisappear(_ animated: Bool) { + let appDelegate = UIApplication.shared.delegate as! AppDelegate + PurchaseManager.shared.delegate = appDelegate + super.viewDidDisappear(animated) + } + override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) + PurchaseManager.shared.delegate = self + if extendingService { if Application.shared.authentication.isLoggedIn && !Application.shared.serviceStatus.isNewStyleAccount() { let serviceType = ServiceType.getType(currentPlan: Application.shared.serviceStatus.currentPlan) service = Service(type: serviceType, duration: .year) } - fetchProducts() + load() } } @@ -175,75 +185,33 @@ class PaymentViewController: UITableViewController { } } - @objc private func fetchProducts() { - displayMode = .loading - - IAPManager.shared.fetchProducts { [weak self] products, error in - guard let self = self else { return } - - if error != nil { - self.showAlert(title: "iTunes Store error", message: "Cannot connect to iTunes Store") - self.displayMode = .error - return - } - - if products != nil { - self.displayMode = .content - } + @objc private func load() { + Task { + await loadProducts() } } - private func purchaseProduct(identifier: String) { - guard deviceCanMakePurchases() else { return } - - hud.indicatorView = JGProgressHUDIndeterminateIndicatorView() - hud.detailTextLabel.text = "Processing payment..." - hud.show(in: (navigationController?.view)!) + private func loadProducts() async { + displayMode = .loading - IAPManager.shared.purchaseProduct(identifier: identifier) { [weak self] purchase, error in - guard let self = self else { return } - - if let error = error { - self.showErrorAlert(title: "Error", message: error) - self.hud.dismiss() - return - } - - if let purchase = purchase { - self.completePurchase(purchase: purchase) - } + do { + try await PurchaseManager.shared.loadProducts() + displayMode = .content + } catch { + showAlert(title: "iTunes Store error", message: "Cannot connect to iTunes Store") + displayMode = .error } } - private func completePurchase(purchase: PurchaseDetails) { - IAPManager.shared.completePurchase(purchase: purchase) { [weak self] serviceStatus, error in - guard let self = self else { return } - - self.hud.dismiss() - - if let error = error { - self.showErrorAlert(title: "Error", message: error.message) { _ in - if error.status == 400 { - self.navigationController?.dismiss(animated: true, completion: nil) - } - } - return - } - - if let serviceStatus = serviceStatus { - self.showSubscriptionActivatedAlert(serviceStatus: serviceStatus) { - if KeyChain.sessionToken == nil { - KeyChain.username = KeyChain.tempUsername - KeyChain.tempUsername = nil - self.sessionManager.createSession() - return - } - - self.navigationController?.dismiss(animated: true) { - NotificationCenter.default.post(name: Notification.Name.SubscriptionActivated, object: nil) - } - } - } + private func purchaseProduct(identifier: String) async { + guard deviceCanMakePurchases() else { + return + } + + do { + _ = try await PurchaseManager.shared.purchase(identifier) + } catch { + showErrorAlert(title: "Error", message: error.localizedDescription) } } @@ -299,6 +267,67 @@ extension PaymentViewController { } +// MARK: - PurchaseManagerDelegate - + +extension PaymentViewController: PurchaseManagerDelegate { + + func purchaseStart() { + DispatchQueue.main.async { [self] in + hud.indicatorView = JGProgressHUDIndeterminateIndicatorView() + hud.detailTextLabel.text = "Processing payment..." + hud.show(in: (navigationController?.view)!) + } + } + + func purchasePending() { + DispatchQueue.main.async { [self] in + hud.dismiss() + showAlert(title: "Pending payment", message: "Payment is pending for approval. We will complete the transaction as soon as payment is approved.") + } + } + + func purchaseSuccess(activeUntil: String, extended: Bool) { + guard extended else { + hud.dismiss() + return + } + + DispatchQueue.main.async { [self] in + hud.dismiss() + + showSubscriptionActivatedAlert(activeUntil: activeUntil) { + if KeyChain.sessionToken == nil { + KeyChain.username = KeyChain.tempUsername + KeyChain.tempUsername = nil + self.sessionManager.createSession() + return + } + + self.navigationController?.dismiss(animated: true) { + NotificationCenter.default.post(name: Notification.Name.SubscriptionActivated, object: nil) + } + } + } + } + + func purchaseError(error: Any?) { + DispatchQueue.main.async { [self] in + hud.dismiss() + + guard let error = error as? ErrorResult else { + return + } + + showErrorAlert(title: "Error", message: error.message) { _ in + if error.status == 400 { + self.navigationController?.dismiss(animated: true, completion: nil) + } + } + } + } + +} + // MARK: - SessionManagerDelegate - extension PaymentViewController { diff --git a/IVPNClient/Scenes/Signup/SelectPlan/SelectPlanViewController.swift b/IVPNClient/Scenes/Signup/SelectPlan/SelectPlanViewController.swift index 77c7aec6c..668dc9ddd 100644 --- a/IVPNClient/Scenes/Signup/SelectPlan/SelectPlanViewController.swift +++ b/IVPNClient/Scenes/Signup/SelectPlan/SelectPlanViewController.swift @@ -59,7 +59,7 @@ class SelectPlanViewController: UITableViewController { lazy var retryButton: UIButton = { let button = UIButton(type: .system) - button.addTarget(self, action: #selector(fetchProducts), for: .touchUpInside) + button.addTarget(self, action: #selector(load), for: .touchUpInside) button.setTitle("Retry", for: .normal) button.sizeToFit() button.isHidden = true @@ -133,7 +133,7 @@ class SelectPlanViewController: UITableViewController { super.viewDidAppear(animated) if displayMode == .loading { - fetchProducts() + load() } segueStarted = false @@ -197,22 +197,22 @@ class SelectPlanViewController: UITableViewController { } } - @objc private func fetchProducts() { + @objc private func load() { + Task { + await loadProducts() + } + } + + private func loadProducts() async { displayMode = .loading - IAPManager.shared.fetchProducts { [weak self] products, error in - guard let self = self else { return } - - if error != nil { - self.showAlert(title: "iTunes Store error", message: "Cannot connect to iTunes Store") - self.displayMode = .error - return - } - - if products != nil { - self.updateSubscriptions() - self.displayMode = .content - } + do { + try await PurchaseManager.shared.loadProducts() + updateSubscriptions() + displayMode = .content + } catch { + showAlert(title: "iTunes Store error", message: "Cannot connect to iTunes Store") + displayMode = .error } } diff --git a/IVPNClient/Store.storekit b/IVPNClient/Store.storekit new file mode 100644 index 000000000..f90319fbe --- /dev/null +++ b/IVPNClient/Store.storekit @@ -0,0 +1,385 @@ +{ + "identifier" : "57E4D561", + "nonRenewingSubscriptions" : [ + { + "displayPrice" : "9.99", + "familyShareable" : false, + "internalID" : "1193681555", + "localizations" : [ + { + "description" : "This In-App purchase will extend VPN subscription for 1 month. \nSubscription can be purchased multiple times by customer. Each purchase will add additional month to the account.", + "displayName" : "1 Month", + "locale" : "en_US" + } + ], + "productID" : "net.ivpn.subscriptions.1month", + "referenceName" : "1 Month", + "type" : "NonRenewingSubscription" + }, + { + "displayPrice" : "9.99", + "familyShareable" : false, + "internalID" : "1515476683", + "localizations" : [ + { + "description" : "IVPN Pro service 1 month non-renewing", + "displayName" : "IVPN Pro 1 month", + "locale" : "en_US" + } + ], + "productID" : "net.ivpn.subscriptions.pro.1month", + "referenceName" : "1 month non-renewing IVPN Pro subscription", + "type" : "NonRenewingSubscription" + }, + { + "displayPrice" : "5.99", + "familyShareable" : false, + "internalID" : "1515475932", + "localizations" : [ + { + "description" : "IVPN Standard service 1 month non-renewing", + "displayName" : "IVPN Standard 1 month", + "locale" : "en_US" + } + ], + "productID" : "net.ivpn.subscriptions.standard.1month", + "referenceName" : "1 month non-renewing IVPN Standard subscription", + "type" : "NonRenewingSubscription" + }, + { + "displayPrice" : "3.99", + "familyShareable" : false, + "internalID" : "1515476549", + "localizations" : [ + { + "description" : "IVPN Pro service 1 week non-renewing", + "displayName" : "IVPN Pro 1 week", + "locale" : "en_US" + } + ], + "productID" : "net.ivpn.subscriptions.pro.1week", + "referenceName" : "1 week non-renewing IVPN Pro subscription", + "type" : "NonRenewingSubscription" + }, + { + "displayPrice" : "1.99", + "familyShareable" : false, + "internalID" : "1515475821", + "localizations" : [ + { + "description" : "IVPN Standard service 1 week non-renewing", + "displayName" : "IVPN Standard 1 week", + "locale" : "en_US" + } + ], + "productID" : "net.ivpn.subscriptions.standard.1week", + "referenceName" : "1 week non-renewing IVPN Standard subscription", + "type" : "NonRenewingSubscription" + }, + { + "displayPrice" : "99.99", + "familyShareable" : false, + "internalID" : "1515476688", + "localizations" : [ + { + "description" : "IVPN Pro service 1 year non-renewing", + "displayName" : "IVPN Pro 1 year", + "locale" : "en_US" + } + ], + "productID" : "net.ivpn.subscriptions.pro.1year", + "referenceName" : "1 year non-renewing IVPN Pro subscription", + "type" : "NonRenewingSubscription" + }, + { + "displayPrice" : "59.99", + "familyShareable" : false, + "internalID" : "1515475940", + "localizations" : [ + { + "description" : "IVPN Standard service 1 year non-renewing", + "displayName" : "IVPN Standard 1 year", + "locale" : "en_US" + } + ], + "productID" : "net.ivpn.subscriptions.standard.1year", + "referenceName" : "1 year non-renewing IVPN Standard subscription", + "type" : "NonRenewingSubscription" + }, + { + "displayPrice" : "99.99", + "familyShareable" : false, + "internalID" : "1193684808", + "localizations" : [ + { + "description" : "This In-App purchase will extend VPN subscription for 12 months. \nSubscription can be purchased multiple times by customer. Each purchase will add additional month to the account.", + "displayName" : "12 Months", + "locale" : "en_US" + } + ], + "productID" : "net.ivpn.subscriptions.12month", + "referenceName" : "12 Months", + "type" : "NonRenewingSubscription" + }, + { + "displayPrice" : "159.99", + "familyShareable" : false, + "internalID" : "1515476948", + "localizations" : [ + { + "description" : "IVPN Pro service 2 years non-renewing", + "displayName" : "IVPN Pro 2 years", + "locale" : "en_US" + } + ], + "productID" : "net.ivpn.subscriptions.pro.2year", + "referenceName" : "2 years non-renewing IVPN Pro subscription", + "type" : "NonRenewingSubscription" + }, + { + "displayPrice" : "99.99", + "familyShareable" : false, + "internalID" : "1515476287", + "localizations" : [ + { + "description" : "IVPN Standard service 2 years non-renewing", + "displayName" : "IVPN Standard 2 years", + "locale" : "en_US" + } + ], + "productID" : "net.ivpn.subscriptions.standard.2year", + "referenceName" : "2 years non-renewing IVPN Standard subscription", + "type" : "NonRenewingSubscription" + }, + { + "displayPrice" : "219.99", + "familyShareable" : false, + "internalID" : "1515477187", + "localizations" : [ + { + "description" : "IVPN Pro service 3 years non-renewing", + "displayName" : "IVPN Pro 3 years", + "locale" : "en_US" + } + ], + "productID" : "net.ivpn.subscriptions.pro.3year", + "referenceName" : "3 years non-renewing IVPN Pro subscription", + "type" : "NonRenewingSubscription" + }, + { + "displayPrice" : "139.99", + "familyShareable" : false, + "internalID" : "1515476242", + "localizations" : [ + { + "description" : "IVPN Standard service 3 years non-renewing", + "displayName" : "IVPN Standard 3 years", + "locale" : "en_US" + } + ], + "productID" : "net.ivpn.subscriptions.standard.3year", + "referenceName" : "3 years non-renewing IVPN Standard subscription", + "type" : "NonRenewingSubscription" + } + ], + "products" : [ + + ], + "settings" : { + "_applicationInternalID" : "1193122683", + "_askToBuyEnabled" : false, + "_developerTeamID" : "WQXXM75BYN", + "_failTransactionsEnabled" : false, + "_lastSynchronizedDate" : 724332316.402367, + "_locale" : "en_US", + "_storefront" : "USA", + "_storeKitErrors" : [ + { + "current" : { + "index" : 2, + "type" : "generic" + }, + "enabled" : false, + "name" : "Load Products" + }, + { + "current" : { + "index" : 1, + "type" : "purchase" + }, + "enabled" : false, + "name" : "Purchase" + }, + { + "current" : null, + "enabled" : false, + "name" : "Verification" + }, + { + "current" : null, + "enabled" : false, + "name" : "App Store Sync" + }, + { + "current" : null, + "enabled" : false, + "name" : "Subscription Status" + }, + { + "current" : null, + "enabled" : false, + "name" : "App Transaction" + }, + { + "current" : null, + "enabled" : false, + "name" : "Manage Subscriptions Sheet" + }, + { + "current" : null, + "enabled" : false, + "name" : "Refund Request Sheet" + }, + { + "current" : null, + "enabled" : false, + "name" : "Offer Code Redeem Sheet" + } + ] + }, + "subscriptionGroups" : [ + { + "id" : "20519080", + "localizations" : [ + + ], + "name" : "IVPN Service", + "subscriptions" : [ + { + "adHocOffers" : [ + + ], + "codeOffers" : [ + + ], + "displayPrice" : "9.99", + "familyShareable" : false, + "groupNumber" : 1, + "internalID" : "1459483527", + "introductoryOffer" : { + "internalID" : "C2DE8327", + "numberOfPeriods" : 1, + "paymentMode" : "free", + "subscriptionPeriod" : "P3D" + }, + "localizations" : [ + { + "description" : "1 month of IVPN Pro service subscription", + "displayName" : "1 month IVPN Pro service", + "locale" : "en_US" + } + ], + "productID" : "net.ivpn.autosubscriptions.1month", + "recurringSubscriptionPeriod" : "P1M", + "referenceName" : "1 month auto-renewable IVPN Pro subscription", + "subscriptionGroupID" : "20519080", + "type" : "RecurringSubscription" + }, + { + "adHocOffers" : [ + + ], + "codeOffers" : [ + + ], + "displayPrice" : "5.99", + "familyShareable" : false, + "groupNumber" : 1, + "internalID" : "1460132096", + "introductoryOffer" : { + "internalID" : "ADA414D2", + "numberOfPeriods" : 1, + "paymentMode" : "free", + "subscriptionPeriod" : "P3D" + }, + "localizations" : [ + { + "description" : "1 month IVPN Standard service subscription", + "displayName" : "1 month IVPN Standard service", + "locale" : "en_US" + } + ], + "productID" : "net.ivpn.autosubscriptions.standard.1month", + "recurringSubscriptionPeriod" : "P1M", + "referenceName" : "1 month auto-renewable IVPN Standard subscription", + "subscriptionGroupID" : "20519080", + "type" : "RecurringSubscription" + }, + { + "adHocOffers" : [ + + ], + "codeOffers" : [ + + ], + "displayPrice" : "99.99", + "familyShareable" : false, + "groupNumber" : 1, + "internalID" : "1459482414", + "introductoryOffer" : { + "internalID" : "50C20442", + "numberOfPeriods" : 1, + "paymentMode" : "free", + "subscriptionPeriod" : "P3D" + }, + "localizations" : [ + { + "description" : "1 year of IVPN Pro service subscription", + "displayName" : "1 year IVPN Pro service", + "locale" : "en_US" + } + ], + "productID" : "net.ivpn.autosubscriptions.12month", + "recurringSubscriptionPeriod" : "P1Y", + "referenceName" : "12 months auto-renewable IVPN Pro subscription", + "subscriptionGroupID" : "20519080", + "type" : "RecurringSubscription" + }, + { + "adHocOffers" : [ + + ], + "codeOffers" : [ + + ], + "displayPrice" : "59.99", + "familyShareable" : false, + "groupNumber" : 1, + "internalID" : "1460131621", + "introductoryOffer" : { + "internalID" : "6D20BF20", + "numberOfPeriods" : 1, + "paymentMode" : "free", + "subscriptionPeriod" : "P3D" + }, + "localizations" : [ + { + "description" : "1 year IVPN Standard service subscription", + "displayName" : "1 year IVPN Standard service", + "locale" : "en_US" + } + ], + "productID" : "net.ivpn.autosubscriptions.standard.12month", + "recurringSubscriptionPeriod" : "P1Y", + "referenceName" : "12 months auto-renewable IVPN Standard subscription", + "subscriptionGroupID" : "20519080", + "type" : "RecurringSubscription" + } + ] + } + ], + "version" : { + "major" : 3, + "minor" : 0 + } +} diff --git a/IVPNClient/Utilities/Extensions/UIViewController+Ext.swift b/IVPNClient/Utilities/Extensions/UIViewController+Ext.swift index 71ca4b474..362af3e8c 100644 --- a/IVPNClient/Utilities/Extensions/UIViewController+Ext.swift +++ b/IVPNClient/Utilities/Extensions/UIViewController+Ext.swift @@ -134,10 +134,10 @@ extension UIViewController { } } - func showSubscriptionActivatedAlert(serviceStatus: ServiceStatus, completion: (() -> Void)? = nil) { + func showSubscriptionActivatedAlert(activeUntil: String, completion: (() -> Void)? = nil) { showAlert( title: "Thank you!", - message: "The payment was successfully processed.\nService is active until: " + serviceStatus.activeUntilString(), + message: "The payment was successfully processed.\nService is active until: " + activeUntil, handler: { _ in if let completion = completion { completion() @@ -262,7 +262,7 @@ extension UIViewController { } func deviceCanMakePurchases() -> Bool { - guard IAPManager.shared.canMakePurchases else { + guard PurchaseManager.shared.canMakePurchases else { showAlert(title: "Error", message: "In-App Purchases are not available on your device.") return false }