From 3290f45a08f18202ae7da4d66014e4018e6a9e66 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Mon, 4 Dec 2023 12:05:04 +0100 Subject: [PATCH 01/43] refactor(payments): create PurchaseManager.swift --- IVPNClient.xcodeproj/project.pbxproj | 4 ++++ IVPNClient/Managers/PurchaseManager.swift | 28 +++++++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 IVPNClient/Managers/PurchaseManager.swift diff --git a/IVPNClient.xcodeproj/project.pbxproj b/IVPNClient.xcodeproj/project.pbxproj index 386cb68ec..816276464 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 */; }; @@ -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 = ""; }; @@ -1006,6 +1008,7 @@ 8206F32224367A240056B465 /* VPNErrorObserver.swift */, 825E836225A4834200938240 /* APIPublicKeyPin.swift */, 826C1F8325DBEF1800314C4B /* DNSManager.swift */, + 8228C8D12B1DE906005977D3 /* PurchaseManager.swift */, ); path = Managers; sourceTree = ""; @@ -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 */, diff --git a/IVPNClient/Managers/PurchaseManager.swift b/IVPNClient/Managers/PurchaseManager.swift new file mode 100644 index 000000000..5a72823f0 --- /dev/null +++ b/IVPNClient/Managers/PurchaseManager.swift @@ -0,0 +1,28 @@ +// +// 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 + +class PurchaseManager { + +} From d983758b95419dfcd2d3dd2d64df07aac6b7b6e5 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Mon, 4 Dec 2023 12:09:34 +0100 Subject: [PATCH 02/43] refactor(payments): set DEPLOYMENT_TARGET to 15.0 --- IVPNClient.xcodeproj/project.pbxproj | 42 ++++++++++++++-------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/IVPNClient.xcodeproj/project.pbxproj b/IVPNClient.xcodeproj/project.pbxproj index 816276464..299245070 100644 --- a/IVPNClient.xcodeproj/project.pbxproj +++ b/IVPNClient.xcodeproj/project.pbxproj @@ -2529,7 +2529,7 @@ GCC_C_LANGUAGE_STANDARD = gnu11; HEADER_SEARCH_PATHS = "\"$(SRCROOT)/IVPNClient/liboqs/include\""; INFOPLIST_FILE = UnitTests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited)"; LIBRARY_SEARCH_PATHS = ( "$(inherited)", @@ -2560,7 +2560,7 @@ GCC_C_LANGUAGE_STANDARD = gnu11; HEADER_SEARCH_PATHS = "\"$(SRCROOT)/IVPNClient/liboqs/include\""; INFOPLIST_FILE = UnitTests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited)"; LIBRARY_SEARCH_PATHS = ( "$(inherited)", @@ -2596,7 +2596,7 @@ GCC_C_LANGUAGE_STANDARD = gnu11; HEADER_SEARCH_PATHS = "\"$(SRCROOT)/IVPNClient/liboqs/include\""; INFOPLIST_FILE = "wireguard-tunnel-provider/Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited)"; LIBRARY_SEARCH_PATHS = ( "$(inherited)", @@ -2636,7 +2636,7 @@ GCC_C_LANGUAGE_STANDARD = gnu11; HEADER_SEARCH_PATHS = "\"$(SRCROOT)/IVPNClient/liboqs/include\""; INFOPLIST_FILE = "wireguard-tunnel-provider/Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited)"; LIBRARY_SEARCH_PATHS = ( "$(inherited)", @@ -2668,7 +2668,7 @@ GCC_C_LANGUAGE_STANDARD = gnu11; HEADER_SEARCH_PATHS = "\"$(SRCROOT)/IVPNClient/liboqs/include\""; INFOPLIST_FILE = UITests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited)"; LIBRARY_SEARCH_PATHS = ( "$(inherited)", @@ -2697,7 +2697,7 @@ GCC_C_LANGUAGE_STANDARD = gnu11; HEADER_SEARCH_PATHS = "\"$(SRCROOT)/IVPNClient/liboqs/include\""; INFOPLIST_FILE = UITests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited)"; LIBRARY_SEARCH_PATHS = ( "$(inherited)", @@ -2725,7 +2725,7 @@ GCC_C_LANGUAGE_STANDARD = gnu11; HEADER_SEARCH_PATHS = "\"$(SRCROOT)/IVPNClient/liboqs/include\""; INFOPLIST_FILE = UITests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited)"; LIBRARY_SEARCH_PATHS = ( "$(inherited)", @@ -2792,7 +2792,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; INFOPLIST_FILE = ""; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; PRODUCT_NAME = ""; @@ -2821,7 +2821,7 @@ ); HEADER_SEARCH_PATHS = "\"$(SRCROOT)/IVPNClient/liboqs/include\""; INFOPLIST_FILE = "$(SRCROOT)/IVPNClient/Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2857,7 +2857,7 @@ GCC_C_LANGUAGE_STANDARD = gnu11; HEADER_SEARCH_PATHS = "\"$(SRCROOT)/IVPNClient/liboqs/include\""; INFOPLIST_FILE = UnitTests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited)"; LIBRARY_SEARCH_PATHS = ( "$(inherited)", @@ -2903,7 +2903,7 @@ GCC_C_LANGUAGE_STANDARD = gnu11; HEADER_SEARCH_PATHS = "\"$(SRCROOT)/IVPNClient/liboqs/include\""; INFOPLIST_FILE = "openvpn-tunnel-provider/Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2946,7 +2946,7 @@ GCC_C_LANGUAGE_STANDARD = gnu11; HEADER_SEARCH_PATHS = "\"$(SRCROOT)/IVPNClient/liboqs/include\""; INFOPLIST_FILE = "wireguard-tunnel-provider/Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited)"; LIBRARY_SEARCH_PATHS = ( "$(inherited)", @@ -2985,7 +2985,7 @@ INFOPLIST_FILE = IVPNWidget/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = IVPNWidget; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 IVPN. All rights reserved."; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited)"; MARKETING_VERSION = 1.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; @@ -3020,7 +3020,7 @@ INFOPLIST_FILE = IVPNWidget/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = IVPNWidget; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 IVPN. All rights reserved."; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited)"; MARKETING_VERSION = 1.0; MTL_FAST_MATH = YES; @@ -3054,7 +3054,7 @@ INFOPLIST_FILE = IVPNWidget/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = IVPNWidget; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 IVPN. All rights reserved."; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited)"; MARKETING_VERSION = 1.0; MTL_FAST_MATH = YES; @@ -3098,7 +3098,7 @@ GCC_C_LANGUAGE_STANDARD = gnu11; HEADER_SEARCH_PATHS = "\"$(SRCROOT)/IVPNClient/liboqs/include\""; INFOPLIST_FILE = "openvpn-tunnel-provider/Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -3150,7 +3150,7 @@ GCC_C_LANGUAGE_STANDARD = gnu11; HEADER_SEARCH_PATHS = "\"$(SRCROOT)/IVPNClient/liboqs/include\""; INFOPLIST_FILE = "openvpn-tunnel-provider/Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -3224,7 +3224,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; INFOPLIST_FILE = ""; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; PRODUCT_NAME = ""; @@ -3281,7 +3281,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; INFOPLIST_FILE = ""; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; PRODUCT_NAME = ""; SDKROOT = iphoneos; @@ -3310,7 +3310,7 @@ ); HEADER_SEARCH_PATHS = "\"$(SRCROOT)/IVPNClient/liboqs/include\""; INFOPLIST_FILE = "$(SRCROOT)/IVPNClient/Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -3349,7 +3349,7 @@ ); HEADER_SEARCH_PATHS = "\"$(SRCROOT)/IVPNClient/liboqs/include\""; INFOPLIST_FILE = "$(SRCROOT)/IVPNClient/Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", From d7d86606009219297d11a7af8719994926a4ff6f Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Mon, 4 Dec 2023 14:25:09 +0100 Subject: [PATCH 03/43] feat(payments): update PurchaseManager.swift --- IVPNClient/Managers/PurchaseManager.swift | 54 ++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/IVPNClient/Managers/PurchaseManager.swift b/IVPNClient/Managers/PurchaseManager.swift index 5a72823f0..83022aab6 100644 --- a/IVPNClient/Managers/PurchaseManager.swift +++ b/IVPNClient/Managers/PurchaseManager.swift @@ -23,6 +23,58 @@ import StoreKit -class PurchaseManager { +class PurchaseManager: NSObject { + + // MARK: - Properties - + + private(set) var products: [Product] = [] + + override init() { + super.init() + SKPaymentQueue.default().add(self) + } + + // MARK: - Methods - + + func loadProducts() async throws { + products = try await Product.products(for: ProductIdentifier.all) + } + + func purchase(_ product: Product) async throws { + let result = try await product.purchase() + + switch result { + case let .success(.verified(transaction)): + // Successful purchase + await transaction.finish() + case .success(.unverified(_, _)): + // Successful purchase but transaction/receipt can't be verified + // Could be a jailbroken phone + break + case .pending: + // Transaction waiting on SCA (Strong Customer Authentication) or + // approval from Ask to Buy + break + case .userCancelled: + // ^^^ + break + @unknown default: + break + } + } + +} + +// MARK: - SKPaymentTransactionObserver - + +extension PurchaseManager: SKPaymentTransactionObserver { + + func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) { + + } + + func paymentQueue(_ queue: SKPaymentQueue, shouldAddStorePayment payment: SKPayment, for product: SKProduct) -> Bool { + return true + } } From cfb1eb751767342312deb5693cbbf7a12d87e481 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Mon, 4 Dec 2023 14:29:07 +0100 Subject: [PATCH 04/43] refactor: update ProductIdentifier model --- IVPNClient.xcodeproj/project.pbxproj | 8 ++++---- IVPNClient/Managers/IAPManager.swift | 2 +- IVPNClient/Managers/PurchaseManager.swift | 2 +- ...roductIdentifier.swift => ProductId.swift} | 12 ++--------- IVPNClient/Models/Service.swift | 20 +++++++++---------- 5 files changed, 18 insertions(+), 26 deletions(-) rename IVPNClient/Models/{ProductIdentifier.swift => ProductId.swift} (90%) diff --git a/IVPNClient.xcodeproj/project.pbxproj b/IVPNClient.xcodeproj/project.pbxproj index 299245070..6a02d59e1 100644 --- a/IVPNClient.xcodeproj/project.pbxproj +++ b/IVPNClient.xcodeproj/project.pbxproj @@ -95,7 +95,7 @@ 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 */; }; @@ -526,7 +526,7 @@ 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 = ""; }; @@ -1024,7 +1024,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 */, @@ -2379,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/Managers/IAPManager.swift b/IVPNClient/Managers/IAPManager.swift index ce9225166..b50ed4023 100644 --- a/IVPNClient/Managers/IAPManager.swift +++ b/IVPNClient/Managers/IAPManager.swift @@ -50,7 +50,7 @@ class IAPManager { // MARK: - Methods - func fetchProducts(completion: @escaping ([SKProduct]?, String?) -> Void) { - SwiftyStoreKit.retrieveProductsInfo(ProductIdentifier.all) { result in + SwiftyStoreKit.retrieveProductsInfo(ProductId.all) { result in if !result.retrievedProducts.isEmpty { self.products = Array(result.retrievedProducts) completion(Array(result.retrievedProducts), nil) diff --git a/IVPNClient/Managers/PurchaseManager.swift b/IVPNClient/Managers/PurchaseManager.swift index 83022aab6..4fbe28f1e 100644 --- a/IVPNClient/Managers/PurchaseManager.swift +++ b/IVPNClient/Managers/PurchaseManager.swift @@ -37,7 +37,7 @@ class PurchaseManager: NSObject { // MARK: - Methods - func loadProducts() async throws { - products = try await Product.products(for: ProductIdentifier.all) + products = try await Product.products(for: ProductId.all) } func purchase(_ product: Product) async throws { 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..1c3d0d14f 100644 --- a/IVPNClient/Models/Service.swift +++ b/IVPNClient/Models/Service.swift @@ -89,28 +89,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 } } } From 60949775b272bdc1a77260c77e811053f29927ff Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Mon, 4 Dec 2023 16:10:13 +0100 Subject: [PATCH 05/43] refactor(payments): update PurchaseManager.swift --- IVPNClient/Managers/IAPManager.swift | 4 ---- IVPNClient/Managers/PurchaseManager.swift | 21 +++++++++++++++++---- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/IVPNClient/Managers/IAPManager.swift b/IVPNClient/Managers/IAPManager.swift index b50ed4023..e46a5d2f3 100644 --- a/IVPNClient/Managers/IAPManager.swift +++ b/IVPNClient/Managers/IAPManager.swift @@ -216,10 +216,6 @@ class IAPManager { return formatter.string(from: product.price) ?? "" } - func completeTransactions() { - SwiftyStoreKit.completeTransactions(atomically: true) { _ in } - } - // MARK: - Private methods - private func purchaseParams(purchase: PurchaseDetails, endpoint: String) -> [URLQueryItem] { diff --git a/IVPNClient/Managers/PurchaseManager.swift b/IVPNClient/Managers/PurchaseManager.swift index 4fbe28f1e..12874ea2d 100644 --- a/IVPNClient/Managers/PurchaseManager.swift +++ b/IVPNClient/Managers/PurchaseManager.swift @@ -27,15 +27,20 @@ class PurchaseManager: NSObject { // MARK: - Properties - - private(set) var products: [Product] = [] + static let shared = PurchaseManager() - override init() { - super.init() - SKPaymentQueue.default().add(self) + var canMakePurchases: Bool { + return SKPaymentQueue.canMakePayments() } + private(set) var products: [Product] = [] + // MARK: - Methods - + func startObserver() { + SKPaymentQueue.default().add(self) + } + func loadProducts() async throws { products = try await Product.products(for: ProductId.all) } @@ -63,6 +68,14 @@ class PurchaseManager: NSObject { } } + func getProduct(id: String) -> Product? { + for product in products where product.id == id { + return product + } + + return nil + } + } // MARK: - SKPaymentTransactionObserver - From e7adaaf5cf1fd4d0f6223d43b8e2461e9a36f7a3 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Mon, 4 Dec 2023 16:43:14 +0100 Subject: [PATCH 06/43] refactor(payments): update PurchaseManager.swift --- IVPNClient/Managers/PurchaseManager.swift | 85 +++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/IVPNClient/Managers/PurchaseManager.swift b/IVPNClient/Managers/PurchaseManager.swift index 12874ea2d..3c09f7fee 100644 --- a/IVPNClient/Managers/PurchaseManager.swift +++ b/IVPNClient/Managers/PurchaseManager.swift @@ -35,6 +35,18 @@ class PurchaseManager: NSObject { private(set) var products: [Product] = [] + 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 startObserver() { @@ -68,6 +80,26 @@ class PurchaseManager: NSObject { } } + func finishPurchase(transaction: Transaction, completion: @escaping (ServiceStatus?, ErrorResult?) -> Void) { + let endpoint = apiEndpoint + let params = purchaseParams(transaction: transaction, endpoint: endpoint) + 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 + // try await transaction.finish() + 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 getProduct(id: String) -> Product? { for product in products where product.id == id { return product @@ -76,6 +108,59 @@ class PurchaseManager: NSObject { return nil } + // MARK: - Private methods - + + private func base64receipt() -> String { + if let appStoreReceiptURL = Bundle.main.appStoreReceiptURL, FileManager.default.fileExists(atPath: appStoreReceiptURL.path) { + do { + let receiptData = try Data(contentsOf: appStoreReceiptURL, options: .alwaysMapped) + return receiptData.base64EncodedString(options: []) + } + catch { + log(.error, message: "Couldn't read receipt data with error: \(error.localizedDescription)") + } + } + + return "" + } + + private func purchaseParams(transaction: Transaction, endpoint: String) -> [URLQueryItem] { + let productId = transaction.productID + let transactionId = transaction.id.formatted() + let receipt = base64receipt() + + switch endpoint { + case Config.apiPaymentInitial: + return [ + URLQueryItem(name: "account_id", value: KeyChain.tempUsername ?? ""), + URLQueryItem(name: "product_id", value: productId), + URLQueryItem(name: "transaction_id", value: transactionId), + URLQueryItem(name: "receipt", value: receipt) + ] + case Config.apiPaymentAdd: + return [ + URLQueryItem(name: "session_token", value: KeyChain.sessionToken ?? ""), + URLQueryItem(name: "product_id", value: productId), + URLQueryItem(name: "transaction_id", value: transactionId), + URLQueryItem(name: "receipt", value: receipt) + ] + case Config.apiPaymentAddLegacy: + return [ + URLQueryItem(name: "username", value: KeyChain.username ?? ""), + URLQueryItem(name: "productId", value: productId), + URLQueryItem(name: "transactionId", value: transactionId), + URLQueryItem(name: "receiptData", value: receipt) + ] + default: + return [] + } + } + + private func restorePurchaseParams() -> [URLQueryItem] { + let receipt = base64receipt() + return [URLQueryItem(name: "receipt", value: receipt)] + } + } // MARK: - SKPaymentTransactionObserver - From 424665f2bb9d0d224a8ed7381e159a416fdbb88a Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Wed, 6 Dec 2023 15:31:36 +0100 Subject: [PATCH 07/43] refactor(payments): update PaymentViewController --- IVPNClient/Models/Service.swift | 8 ++++-- .../Payment/PaymentViewController.swift | 28 +++++++++---------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/IVPNClient/Models/Service.swift b/IVPNClient/Models/Service.swift index 1c3d0d14f..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 { diff --git a/IVPNClient/Scenes/Signup/Payment/PaymentViewController.swift b/IVPNClient/Scenes/Signup/Payment/PaymentViewController.swift index ca64c8302..c3c6a6b05 100644 --- a/IVPNClient/Scenes/Signup/Payment/PaymentViewController.swift +++ b/IVPNClient/Scenes/Signup/Payment/PaymentViewController.swift @@ -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(loadProducts), for: .touchUpInside) button.setTitle("Retry", for: .normal) button.sizeToFit() button.isHidden = true @@ -128,7 +128,11 @@ class PaymentViewController: UITableViewController { service = Service(type: serviceType, duration: .year) } - fetchProducts() + Task { @MainActor in + do { + await loadProducts() + } + } } } @@ -175,21 +179,15 @@ class PaymentViewController: UITableViewController { } } - @objc private func fetchProducts() { + @objc 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.displayMode = .content - } + do { + try await PurchaseManager.shared.loadProducts() + displayMode = .content + } catch { + showAlert(title: "iTunes Store error", message: "Cannot connect to iTunes Store") + displayMode = .error } } From a946b46d02beacb39382316851b4e9556b5434c3 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Wed, 6 Dec 2023 15:36:01 +0100 Subject: [PATCH 08/43] refactor(payments): update PaymentViewController --- .../Signup/Payment/PaymentViewController.swift | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/IVPNClient/Scenes/Signup/Payment/PaymentViewController.swift b/IVPNClient/Scenes/Signup/Payment/PaymentViewController.swift index c3c6a6b05..8bf5a3dbd 100644 --- a/IVPNClient/Scenes/Signup/Payment/PaymentViewController.swift +++ b/IVPNClient/Scenes/Signup/Payment/PaymentViewController.swift @@ -54,7 +54,7 @@ class PaymentViewController: UITableViewController { lazy var retryButton: UIButton = { let button = UIButton(type: .system) - button.addTarget(self, action: #selector(loadProducts), for: .touchUpInside) + button.addTarget(self, action: #selector(load), for: .touchUpInside) button.setTitle("Retry", for: .normal) button.sizeToFit() button.isHidden = true @@ -128,11 +128,7 @@ class PaymentViewController: UITableViewController { service = Service(type: serviceType, duration: .year) } - Task { @MainActor in - do { - await loadProducts() - } - } + load() } } @@ -179,7 +175,15 @@ class PaymentViewController: UITableViewController { } } - @objc private func loadProducts() async { + @objc private func load() { + Task { @MainActor in + do { + await loadProducts() + } + } + } + + private func loadProducts() async { displayMode = .loading do { From 7ace0ccbdd96eff0ad1627acf33270d1bb6ff698 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Wed, 6 Dec 2023 15:55:08 +0100 Subject: [PATCH 09/43] refactor(payments): update UIViewController+Ext.swift --- IVPNClient/Utilities/Extensions/UIViewController+Ext.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/IVPNClient/Utilities/Extensions/UIViewController+Ext.swift b/IVPNClient/Utilities/Extensions/UIViewController+Ext.swift index 3b35775fd..410556486 100644 --- a/IVPNClient/Utilities/Extensions/UIViewController+Ext.swift +++ b/IVPNClient/Utilities/Extensions/UIViewController+Ext.swift @@ -256,7 +256,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 } From 7dfa42b8a190f1b49fbf8dfbc2a192fdc4b71a62 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Thu, 7 Dec 2023 15:44:19 +0100 Subject: [PATCH 10/43] refactor(payments): update PaymentViewController --- IVPNClient/Managers/PurchaseManager.swift | 23 +++++++++-- .../Payment/PaymentViewController.swift | 39 ++++++++++--------- 2 files changed, 40 insertions(+), 22 deletions(-) diff --git a/IVPNClient/Managers/PurchaseManager.swift b/IVPNClient/Managers/PurchaseManager.swift index 3c09f7fee..c470b9530 100644 --- a/IVPNClient/Managers/PurchaseManager.swift +++ b/IVPNClient/Managers/PurchaseManager.swift @@ -57,13 +57,18 @@ class PurchaseManager: NSObject { products = try await Product.products(for: ProductId.all) } - func purchase(_ product: Product) async throws { + func purchase(_ productId: String) async throws -> Transaction? { + guard let product = getProduct(id: productId) else { + return nil + } + let result = try await product.purchase() switch result { case let .success(.verified(transaction)): // Successful purchase - await transaction.finish() + // await transaction.finish() + return transaction case .success(.unverified(_, _)): // Successful purchase but transaction/receipt can't be verified // Could be a jailbroken phone @@ -78,9 +83,11 @@ class PurchaseManager: NSObject { @unknown default: break } + + return nil } - func finishPurchase(transaction: Transaction, completion: @escaping (ServiceStatus?, ErrorResult?) -> Void) { + func completePurchase(transaction: Transaction, completion: @escaping (ServiceStatus?, ErrorResult?) -> Void) { let endpoint = apiEndpoint let params = purchaseParams(transaction: transaction, endpoint: endpoint) let request = ApiRequestDI(method: .post, endpoint: endpoint, params: params) @@ -89,7 +96,7 @@ class PurchaseManager: NSObject { switch result { case .success(let sessionStatus): Application.shared.serviceStatus = sessionStatus.serviceStatus - // try await transaction.finish() + self.finishTransaction(transaction) completion(sessionStatus.serviceStatus, nil) log(.info, message: "Purchase was successfully finished.") case .failure(let error): @@ -100,6 +107,14 @@ class PurchaseManager: NSObject { } } + func finishTransaction(_ transaction: Transaction) { + Task { @MainActor in + do { + await transaction.finish() + } + } + } + func getProduct(id: String) -> Product? { for product in products where product.id == id { return product diff --git a/IVPNClient/Scenes/Signup/Payment/PaymentViewController.swift b/IVPNClient/Scenes/Signup/Payment/PaymentViewController.swift index 8bf5a3dbd..0192f77c2 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 @@ -101,7 +101,11 @@ class PaymentViewController: UITableViewController { } @IBAction func purchase(_ sender: UIButton) { - purchaseProduct(identifier: service.productId) + Task { @MainActor in + do { + await purchaseProduct(identifier: service.productId) + } + } } @IBAction func close() { @@ -195,31 +199,30 @@ class PaymentViewController: UITableViewController { } } - private func purchaseProduct(identifier: String) { - guard deviceCanMakePurchases() else { return } + private func purchaseProduct(identifier: String) async { + guard deviceCanMakePurchases() else { + return + } hud.indicatorView = JGProgressHUDIndeterminateIndicatorView() hud.detailTextLabel.text = "Processing payment..." hud.show(in: (navigationController?.view)!) - 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 { + if let transaction = try await PurchaseManager.shared.purchase(identifier) { + completePurchase(transaction: transaction) } + } catch { + showErrorAlert(title: "Error", message: error.localizedDescription) + hud.dismiss() } } - private func completePurchase(purchase: PurchaseDetails) { - IAPManager.shared.completePurchase(purchase: purchase) { [weak self] serviceStatus, error in - guard let self = self else { return } + private func completePurchase(transaction: Transaction) { + PurchaseManager.shared.completePurchase(transaction: transaction) { [weak self] serviceStatus, error in + guard let self = self else { + return + } self.hud.dismiss() From c814d6e70e747091c38501a34e6737775b55dd6d Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Thu, 7 Dec 2023 21:35:03 +0100 Subject: [PATCH 11/43] refactor(payments): update PurchaseManager.swift --- IVPNClient/Managers/PurchaseManager.swift | 6 ++---- .../Signup/Payment/PaymentViewController.swift | 12 ++++-------- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/IVPNClient/Managers/PurchaseManager.swift b/IVPNClient/Managers/PurchaseManager.swift index c470b9530..01359d430 100644 --- a/IVPNClient/Managers/PurchaseManager.swift +++ b/IVPNClient/Managers/PurchaseManager.swift @@ -108,10 +108,8 @@ class PurchaseManager: NSObject { } func finishTransaction(_ transaction: Transaction) { - Task { @MainActor in - do { - await transaction.finish() - } + Task { + await transaction.finish() } } diff --git a/IVPNClient/Scenes/Signup/Payment/PaymentViewController.swift b/IVPNClient/Scenes/Signup/Payment/PaymentViewController.swift index 0192f77c2..694b02b14 100644 --- a/IVPNClient/Scenes/Signup/Payment/PaymentViewController.swift +++ b/IVPNClient/Scenes/Signup/Payment/PaymentViewController.swift @@ -101,10 +101,8 @@ class PaymentViewController: UITableViewController { } @IBAction func purchase(_ sender: UIButton) { - Task { @MainActor in - do { - await purchaseProduct(identifier: service.productId) - } + Task { + await purchaseProduct(identifier: service.productId) } } @@ -180,10 +178,8 @@ class PaymentViewController: UITableViewController { } @objc private func load() { - Task { @MainActor in - do { - await loadProducts() - } + Task { + await loadProducts() } } From 03b4e359d7254ef4fed7e8fee0c2219b04ff19d9 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Thu, 7 Dec 2023 21:39:37 +0100 Subject: [PATCH 12/43] refactor: update LoginViewController --- IVPNClient/Scenes/Signup/LoginViewController.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/IVPNClient/Scenes/Signup/LoginViewController.swift b/IVPNClient/Scenes/Signup/LoginViewController.swift index 14a3b5eac..023db0825 100644 --- a/IVPNClient/Scenes/Signup/LoginViewController.swift +++ b/IVPNClient/Scenes/Signup/LoginViewController.swift @@ -110,8 +110,8 @@ class LoginViewController: UIViewController { return } - if account != nil { - self.userName.text = account?.accountId + if let account = account { + self.userName.text = account.accountId self.sessionManager.createSession() } } From 04665f0f3cce453fb346d7a8e6364a11708fac69 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Fri, 8 Dec 2023 11:05:42 +0100 Subject: [PATCH 13/43] refactor(payments): update SelectPlanViewController --- .../SelectPlan/SelectPlanViewController.swift | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) 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 } } From e2578a6c7553467f9a8e004c7db05d49bb5272ba Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Mon, 11 Dec 2023 11:41:29 +0100 Subject: [PATCH 14/43] feat(payments): update PurchaseManager --- IVPNClient/Managers/PurchaseManager.swift | 40 +++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/IVPNClient/Managers/PurchaseManager.swift b/IVPNClient/Managers/PurchaseManager.swift index 01359d430..b2b2f5645 100644 --- a/IVPNClient/Managers/PurchaseManager.swift +++ b/IVPNClient/Managers/PurchaseManager.swift @@ -87,6 +87,23 @@ class PurchaseManager: NSObject { return nil } + 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.completeRestoredPurchase(transaction: transaction) { account, error in + completion(account, error) + log(.info, message: "Purchases are restored.") + } + } + } + } + } + func completePurchase(transaction: Transaction, completion: @escaping (ServiceStatus?, ErrorResult?) -> Void) { let endpoint = apiEndpoint let params = purchaseParams(transaction: transaction, endpoint: endpoint) @@ -107,6 +124,29 @@ class PurchaseManager: NSObject { } } + func completeRestoredPurchase(transaction: Transaction, 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): + self.finishTransaction(transaction) + KeyChain.username = account.accountId + completion(account, nil) + log(.info, message: "Purchase was successfully restored.") + 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 sync() async -> Bool { + return ((try? await AppStore.sync()) != nil) + } + func finishTransaction(_ transaction: Transaction) { Task { await transaction.finish() From a72c9bff676c5da8e6271edc8e28e6fe9188bc11 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Mon, 11 Dec 2023 11:42:07 +0100 Subject: [PATCH 15/43] refactor(payments): update LoginViewController --- IVPNClient/Scenes/Signup/LoginViewController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/IVPNClient/Scenes/Signup/LoginViewController.swift b/IVPNClient/Scenes/Signup/LoginViewController.swift index 023db0825..94cdff23c 100644 --- a/IVPNClient/Scenes/Signup/LoginViewController.swift +++ b/IVPNClient/Scenes/Signup/LoginViewController.swift @@ -102,7 +102,7 @@ class LoginViewController: UIViewController { hud.detailTextLabel.text = "Restoring purchases..." hud.show(in: (navigationController?.view)!) - IAPManager.shared.restorePurchases { account, error in + PurchaseManager.shared.restorePurchases { account, error in self.hud.dismiss() if let error = error { From 191d2f34619b6f66c986965e1ce6e497a562347c Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Tue, 12 Dec 2023 10:28:42 +0100 Subject: [PATCH 16/43] feat(payments): update PurchaseManager --- IVPNClient/AppDelegate.swift | 2 +- IVPNClient/Managers/PurchaseManager.swift | 26 ++++++++++++++----- .../Payment/PaymentViewController.swift | 2 +- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/IVPNClient/AppDelegate.swift b/IVPNClient/AppDelegate.swift index 5bd838243..1f698a036 100644 --- a/IVPNClient/AppDelegate.swift +++ b/IVPNClient/AppDelegate.swift @@ -85,7 +85,7 @@ class AppDelegate: UIResponder { return } - IAPManager.shared.finishIncompletePurchases { serviceStatus, _ in + PurchaseManager.shared.completeUnfinishedTransactions { serviceStatus, _ in guard let viewController = UIApplication.topViewController() else { return } if let serviceStatus = serviceStatus { diff --git a/IVPNClient/Managers/PurchaseManager.swift b/IVPNClient/Managers/PurchaseManager.swift index b2b2f5645..a3bc880eb 100644 --- a/IVPNClient/Managers/PurchaseManager.swift +++ b/IVPNClient/Managers/PurchaseManager.swift @@ -95,7 +95,7 @@ class PurchaseManager: NSObject { } if transaction.revocationDate == nil { - self.completeRestoredPurchase(transaction: transaction) { account, error in + self.getAccountFor(transaction: transaction) { account, error in completion(account, error) log(.info, message: "Purchases are restored.") } @@ -104,7 +104,23 @@ class PurchaseManager: NSObject { } } - func completePurchase(transaction: Transaction, completion: @escaping (ServiceStatus?, ErrorResult?) -> Void) { + func completeUnfinishedTransactions(completion: @escaping (ServiceStatus?, ErrorResult?) -> Void) { + Task { + for await result in Transaction.unfinished { + guard case .verified(let transaction) = result else { + continue + } + + if transaction.revocationDate == nil { + complete(transaction: transaction) { serviceStatus, error in + completion(serviceStatus, error) + } + } + } + } + } + + func complete(transaction: Transaction, completion: @escaping (ServiceStatus?, ErrorResult?) -> Void) { let endpoint = apiEndpoint let params = purchaseParams(transaction: transaction, endpoint: endpoint) let request = ApiRequestDI(method: .post, endpoint: endpoint, params: params) @@ -124,7 +140,7 @@ class PurchaseManager: NSObject { } } - func completeRestoredPurchase(transaction: Transaction, completion: @escaping (Account?, ErrorResult?) -> Void) { + func getAccountFor(transaction: Transaction, completion: @escaping (Account?, ErrorResult?) -> Void) { let params = restorePurchaseParams() let request = ApiRequestDI(method: .post, endpoint: Config.apiPaymentRestore, params: params) @@ -143,10 +159,6 @@ class PurchaseManager: NSObject { } } - func sync() async -> Bool { - return ((try? await AppStore.sync()) != nil) - } - func finishTransaction(_ transaction: Transaction) { Task { await transaction.finish() diff --git a/IVPNClient/Scenes/Signup/Payment/PaymentViewController.swift b/IVPNClient/Scenes/Signup/Payment/PaymentViewController.swift index 694b02b14..86cb1e1e1 100644 --- a/IVPNClient/Scenes/Signup/Payment/PaymentViewController.swift +++ b/IVPNClient/Scenes/Signup/Payment/PaymentViewController.swift @@ -215,7 +215,7 @@ class PaymentViewController: UITableViewController { } private func completePurchase(transaction: Transaction) { - PurchaseManager.shared.completePurchase(transaction: transaction) { [weak self] serviceStatus, error in + PurchaseManager.shared.complete(transaction: transaction) { [weak self] serviceStatus, error in guard let self = self else { return } From 11fc204e111e8ab8eb683353cc95a01da9879dba Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Tue, 12 Dec 2023 11:12:14 +0100 Subject: [PATCH 17/43] feat(payments): update error handling --- IVPNClient/Managers/PurchaseManager.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/IVPNClient/Managers/PurchaseManager.swift b/IVPNClient/Managers/PurchaseManager.swift index a3bc880eb..10e38668e 100644 --- a/IVPNClient/Managers/PurchaseManager.swift +++ b/IVPNClient/Managers/PurchaseManager.swift @@ -96,11 +96,16 @@ class PurchaseManager: NSObject { if transaction.revocationDate == nil { self.getAccountFor(transaction: transaction) { account, error in + log(.info, message: "Purchase is restored.") completion(account, error) - log(.info, message: "Purchases are restored.") + return } } } + + let error = ErrorResult(status: 500, message: "There are no purchases to restore.") + log(.error, message: "There are no purchases to restore.") + completion(nil, error) } } From e9fd70701571ed932188cf66b16fb0fa34f1fd62 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Tue, 12 Dec 2023 12:02:09 +0100 Subject: [PATCH 18/43] chore: clean up PurchaseManager --- IVPNClient/Managers/PurchaseManager.swift | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/IVPNClient/Managers/PurchaseManager.swift b/IVPNClient/Managers/PurchaseManager.swift index 10e38668e..c0ada5a4d 100644 --- a/IVPNClient/Managers/PurchaseManager.swift +++ b/IVPNClient/Managers/PurchaseManager.swift @@ -49,10 +49,6 @@ class PurchaseManager: NSObject { // MARK: - Methods - - func startObserver() { - SKPaymentQueue.default().add(self) - } - func loadProducts() async throws { products = try await Product.products(for: ProductId.all) } @@ -232,17 +228,3 @@ class PurchaseManager: NSObject { } } - -// MARK: - SKPaymentTransactionObserver - - -extension PurchaseManager: SKPaymentTransactionObserver { - - func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) { - - } - - func paymentQueue(_ queue: SKPaymentQueue, shouldAddStorePayment payment: SKPayment, for product: SKProduct) -> Bool { - return true - } - -} From 74f5a128e17a1bafa0bf8a343901d8d8dd95149d Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Tue, 12 Dec 2023 13:28:36 +0100 Subject: [PATCH 19/43] feat(payments): update error handling --- IVPNClient/AppDelegate.swift | 5 + IVPNClient/Managers/PurchaseManager.swift | 113 +++++++++++++++------- 2 files changed, 83 insertions(+), 35 deletions(-) diff --git a/IVPNClient/AppDelegate.swift b/IVPNClient/AppDelegate.swift index 1f698a036..3c57f09ff 100644 --- a/IVPNClient/AppDelegate.swift +++ b/IVPNClient/AppDelegate.swift @@ -94,6 +94,10 @@ class AppDelegate: UIResponder { } } + private func listenTransactionUpdates() { + PurchaseManager.shared.listenTransactionUpdates() + } + private func resetLastPingTimestamp() { UserDefaults.shared.set(0, forKey: "LastPingTimestamp") } @@ -295,6 +299,7 @@ extension AppDelegate: UIApplicationDelegate { evaluateUITests() registerUserDefaults() finishIncompletePurchases() + listenTransactionUpdates() createLogFiles() resetLastPingTimestamp() clearURLCache() diff --git a/IVPNClient/Managers/PurchaseManager.swift b/IVPNClient/Managers/PurchaseManager.swift index c0ada5a4d..a1f1a4899 100644 --- a/IVPNClient/Managers/PurchaseManager.swift +++ b/IVPNClient/Managers/PurchaseManager.swift @@ -33,6 +33,8 @@ class PurchaseManager: NSObject { return SKPaymentQueue.canMakePayments() } + var updateListenerTask: Task? = nil + private(set) var products: [Product] = [] private var apiEndpoint: String { @@ -49,6 +51,10 @@ class PurchaseManager: NSObject { // MARK: - Methods - + func listenTransactionUpdates() { + updateListenerTask = listenForTransactions() + } + func loadProducts() async throws { products = try await Product.products(for: ProductId.all) } @@ -83,25 +89,17 @@ class PurchaseManager: NSObject { return nil } - func restorePurchases(completion: @escaping (Account?, ErrorResult?) -> Void) { - Task { - for await result in Transaction.currentEntitlements { + func listenForTransactions() -> Task { + return Task { + for await result in Transaction.updates { guard case .verified(let transaction) = result else { continue } if transaction.revocationDate == nil { - self.getAccountFor(transaction: transaction) { account, error in - log(.info, message: "Purchase is restored.") - completion(account, error) - return - } + complete(transaction: transaction) { _, _ in } } } - - let error = ErrorResult(status: 500, message: "There are no purchases to restore.") - log(.error, message: "There are no purchases to restore.") - completion(nil, error) } } @@ -121,9 +119,43 @@ class PurchaseManager: NSObject { } } + 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 + log(.info, message: "Purchase is restored.") + completion(account, error) + return + } + } + } + + let error = ErrorResult(status: 500, message: "There are no purchases to restore.") + log(.error, message: "There are no purchases to restore.") + completion(nil, error) + } + } + + func finishTransaction(_ transaction: Transaction) { + Task { + await transaction.finish() + } + } + func complete(transaction: Transaction, completion: @escaping (ServiceStatus?, ErrorResult?) -> Void) { + let defaultError = ErrorResult(status: 500, message: "Purchase was completed but service cannot be activated. Restart application to retry.") let endpoint = apiEndpoint - let params = purchaseParams(transaction: transaction, endpoint: endpoint) + + guard let params = purchaseParams(transaction: transaction, endpoint: endpoint) else { + completion(nil, defaultError) + return + } + let request = ApiRequestDI(method: .post, endpoint: endpoint, params: params) ApiService.shared.requestCustomError(request) { (result: ResultCustomError) in @@ -134,15 +166,19 @@ class PurchaseManager: NSObject { 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) + completion(nil, error ?? defaultError) log(.error, message: "There was an error with purchase completion: \(error?.message ?? "")") } } } func getAccountFor(transaction: Transaction, completion: @escaping (Account?, ErrorResult?) -> Void) { - let params = restorePurchaseParams() + let defaultError = ErrorResult(status: 500, message: "Purchase was restored but service cannot be activated. Restart application to retry.") + guard let params = restorePurchaseParams() else { + completion(nil, defaultError) + return + } + let request = ApiRequestDI(method: .post, endpoint: Config.apiPaymentRestore, params: params) ApiService.shared.requestCustomError(request) { (result: ResultCustomError) in @@ -153,19 +189,12 @@ class PurchaseManager: NSObject { completion(account, nil) log(.info, message: "Purchase was successfully restored.") 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) + completion(nil, error ?? defaultError) log(.error, message: "There was an error with purchase completion: \(error?.message ?? "")") } } } - func finishTransaction(_ transaction: Transaction) { - Task { - await transaction.finish() - } - } - func getProduct(id: String) -> Product? { for product in products where product.id == id { return product @@ -176,10 +205,10 @@ class PurchaseManager: NSObject { // MARK: - Private methods - - private func base64receipt() -> String { - if let appStoreReceiptURL = Bundle.main.appStoreReceiptURL, FileManager.default.fileExists(atPath: appStoreReceiptURL.path) { + private func base64receipt() -> String? { + if let receiptURL = Bundle.main.appStoreReceiptURL, FileManager.default.fileExists(atPath: receiptURL.path) { do { - let receiptData = try Data(contentsOf: appStoreReceiptURL, options: .alwaysMapped) + let receiptData = try Data(contentsOf: receiptURL, options: .alwaysMapped) return receiptData.base64EncodedString(options: []) } catch { @@ -187,32 +216,43 @@ class PurchaseManager: NSObject { } } - return "" + return nil } - private func purchaseParams(transaction: Transaction, endpoint: String) -> [URLQueryItem] { + private func purchaseParams(transaction: Transaction, endpoint: String) -> [URLQueryItem]? { let productId = transaction.productID let transactionId = transaction.id.formatted() - let receipt = base64receipt() + guard let receipt = base64receipt() else { + return nil + } switch endpoint { case Config.apiPaymentInitial: + guard let tempUsername = KeyChain.tempUsername else { + return nil + } return [ - URLQueryItem(name: "account_id", value: KeyChain.tempUsername ?? ""), + URLQueryItem(name: "account_id", value: tempUsername), URLQueryItem(name: "product_id", value: productId), URLQueryItem(name: "transaction_id", value: transactionId), URLQueryItem(name: "receipt", value: receipt) ] case Config.apiPaymentAdd: + guard let sessionToken = KeyChain.sessionToken else { + return nil + } return [ - URLQueryItem(name: "session_token", value: KeyChain.sessionToken ?? ""), + URLQueryItem(name: "session_token", value: sessionToken), URLQueryItem(name: "product_id", value: productId), URLQueryItem(name: "transaction_id", value: transactionId), URLQueryItem(name: "receipt", value: receipt) ] case Config.apiPaymentAddLegacy: + guard let username = KeyChain.username else { + return nil + } return [ - URLQueryItem(name: "username", value: KeyChain.username ?? ""), + URLQueryItem(name: "username", value: username), URLQueryItem(name: "productId", value: productId), URLQueryItem(name: "transactionId", value: transactionId), URLQueryItem(name: "receiptData", value: receipt) @@ -222,8 +262,11 @@ class PurchaseManager: NSObject { } } - private func restorePurchaseParams() -> [URLQueryItem] { - let receipt = base64receipt() + private func restorePurchaseParams() -> [URLQueryItem]? { + guard let receipt = base64receipt() else { + return nil + } + return [URLQueryItem(name: "receipt", value: receipt)] } From 53eb71881e74f5e75c2a6789e46f6fb508f17119 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Tue, 12 Dec 2023 14:15:39 +0100 Subject: [PATCH 20/43] feat(payments): update PurchaseManager --- IVPNClient/AppDelegate.swift | 14 ++++- IVPNClient/Managers/PurchaseManager.swift | 54 ++++++++++--------- .../Payment/PaymentViewController.swift | 2 +- 3 files changed, 41 insertions(+), 29 deletions(-) diff --git a/IVPNClient/AppDelegate.swift b/IVPNClient/AppDelegate.swift index 3c57f09ff..23f51af9f 100644 --- a/IVPNClient/AppDelegate.swift +++ b/IVPNClient/AppDelegate.swift @@ -86,7 +86,9 @@ class AppDelegate: UIResponder { } PurchaseManager.shared.completeUnfinishedTransactions { serviceStatus, _ in - guard let viewController = UIApplication.topViewController() else { return } + guard let viewController = UIApplication.topViewController() else { + return + } if let serviceStatus = serviceStatus { viewController.showSubscriptionActivatedAlert(serviceStatus: serviceStatus) @@ -95,7 +97,15 @@ class AppDelegate: UIResponder { } private func listenTransactionUpdates() { - PurchaseManager.shared.listenTransactionUpdates() + PurchaseManager.shared.listenTransactionUpdates { serviceStatus, _ in + guard let viewController = UIApplication.topViewController() else { + return + } + + if let serviceStatus = serviceStatus { + viewController.showSubscriptionActivatedAlert(serviceStatus: serviceStatus) + } + } } private func resetLastPingTimestamp() { diff --git a/IVPNClient/Managers/PurchaseManager.swift b/IVPNClient/Managers/PurchaseManager.swift index a1f1a4899..e50e6b33f 100644 --- a/IVPNClient/Managers/PurchaseManager.swift +++ b/IVPNClient/Managers/PurchaseManager.swift @@ -49,16 +49,24 @@ class PurchaseManager: NSObject { return Config.apiPaymentInitial } - // MARK: - Methods - - - func listenTransactionUpdates() { - updateListenerTask = listenForTransactions() + deinit { + updateListenerTask?.cancel() } + // 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 -> Transaction? { guard let product = getProduct(id: productId) else { return nil @@ -89,15 +97,17 @@ class PurchaseManager: NSObject { return nil } - func listenForTransactions() -> Task { - return Task { + func listenTransactionUpdates(completion: @escaping (ServiceStatus?, ErrorResult?) -> Void) { + updateListenerTask = Task { for await result in Transaction.updates { guard case .verified(let transaction) = result else { continue } if transaction.revocationDate == nil { - complete(transaction: transaction) { _, _ in } + complete(transaction) { serviceStatus, error in + completion(serviceStatus, error) + } } } } @@ -111,7 +121,7 @@ class PurchaseManager: NSObject { } if transaction.revocationDate == nil { - complete(transaction: transaction) { serviceStatus, error in + complete(transaction) { serviceStatus, error in completion(serviceStatus, error) } } @@ -141,13 +151,7 @@ class PurchaseManager: NSObject { } } - func finishTransaction(_ transaction: Transaction) { - Task { - await transaction.finish() - } - } - - func complete(transaction: Transaction, completion: @escaping (ServiceStatus?, ErrorResult?) -> Void) { + func complete(_ transaction: Transaction, completion: @escaping (ServiceStatus?, ErrorResult?) -> Void) { let defaultError = ErrorResult(status: 500, message: "Purchase was completed but service cannot be activated. Restart application to retry.") let endpoint = apiEndpoint @@ -172,7 +176,15 @@ class PurchaseManager: NSObject { } } - func getAccountFor(transaction: Transaction, completion: @escaping (Account?, ErrorResult?) -> Void) { + // MARK: - Private methods - + + private func finishTransaction(_ transaction: Transaction) { + Task { + await transaction.finish() + } + } + + 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() else { completion(nil, defaultError) @@ -195,16 +207,6 @@ class PurchaseManager: NSObject { } } - func getProduct(id: String) -> Product? { - for product in products where product.id == id { - return product - } - - return nil - } - - // MARK: - Private methods - - private func base64receipt() -> String? { if let receiptURL = Bundle.main.appStoreReceiptURL, FileManager.default.fileExists(atPath: receiptURL.path) { do { diff --git a/IVPNClient/Scenes/Signup/Payment/PaymentViewController.swift b/IVPNClient/Scenes/Signup/Payment/PaymentViewController.swift index 86cb1e1e1..a91c34655 100644 --- a/IVPNClient/Scenes/Signup/Payment/PaymentViewController.swift +++ b/IVPNClient/Scenes/Signup/Payment/PaymentViewController.swift @@ -215,7 +215,7 @@ class PaymentViewController: UITableViewController { } private func completePurchase(transaction: Transaction) { - PurchaseManager.shared.complete(transaction: transaction) { [weak self] serviceStatus, error in + PurchaseManager.shared.complete(transaction) { [weak self] serviceStatus, error in guard let self = self else { return } From 76700b1c6334316b5fd03e63a5eec75234ef103c Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Wed, 13 Dec 2023 13:52:12 +0100 Subject: [PATCH 21/43] refactor(payments): update PurchaseManager --- IVPNClient/Managers/PurchaseManager.swift | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/IVPNClient/Managers/PurchaseManager.swift b/IVPNClient/Managers/PurchaseManager.swift index e50e6b33f..a7681b47d 100644 --- a/IVPNClient/Managers/PurchaseManager.swift +++ b/IVPNClient/Managers/PurchaseManager.swift @@ -38,15 +38,11 @@ class PurchaseManager: NSObject { private(set) var products: [Product] = [] private var apiEndpoint: String { - if KeyChain.sessionToken != nil { - if !Application.shared.serviceStatus.isNewStyleAccount() { - return Config.apiPaymentAddLegacy - } - - return Config.apiPaymentAdd + guard let _ = KeyChain.sessionToken else { + return Config.apiPaymentInitial } - return Config.apiPaymentInitial + return Application.shared.serviceStatus.isNewStyleAccount() ? Config.apiPaymentAdd : Config.apiPaymentAddLegacy } deinit { @@ -77,18 +73,21 @@ class PurchaseManager: NSObject { switch result { case let .success(.verified(transaction)): // Successful purchase - // await transaction.finish() + log(.info, message: "Purchase \(productId): success") return transaction case .success(.unverified(_, _)): // Successful purchase but transaction/receipt can't be verified // Could be a jailbroken phone + log(.info, message: "Purchase \(productId): success, unverified") break case .pending: // Transaction waiting on SCA (Strong Customer Authentication) or // approval from Ask to Buy + log(.info, message: "Purchase \(productId): pending") break case .userCancelled: // ^^^ + log(.info, message: "Purchase \(productId): userCancelled") break @unknown default: break @@ -105,6 +104,7 @@ class PurchaseManager: NSObject { } if transaction.revocationDate == nil { + log(.info, message: "Completing updated transaction.") complete(transaction) { serviceStatus, error in completion(serviceStatus, error) } @@ -121,6 +121,7 @@ class PurchaseManager: NSObject { } if transaction.revocationDate == nil { + log(.info, message: "Completing unfinished transaction.") complete(transaction) { serviceStatus, error in completion(serviceStatus, error) } @@ -260,7 +261,7 @@ class PurchaseManager: NSObject { URLQueryItem(name: "receiptData", value: receipt) ] default: - return [] + return nil } } From c8c730a7dc69385521254652ea07db306cc3b1a4 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Thu, 14 Dec 2023 17:14:37 +0100 Subject: [PATCH 22/43] refactor(payments): update AppDelegate --- IVPNClient/AppDelegate.swift | 23 +++++------------------ 1 file changed, 5 insertions(+), 18 deletions(-) diff --git a/IVPNClient/AppDelegate.swift b/IVPNClient/AppDelegate.swift index 23f51af9f..547c193de 100644 --- a/IVPNClient/AppDelegate.swift +++ b/IVPNClient/AppDelegate.swift @@ -80,27 +80,15 @@ class AppDelegate: UIResponder { FileSystemManager.createLogFiles() } - private func finishIncompletePurchases() { - guard Application.shared.authentication.isLoggedIn || KeyChain.tempUsername != nil else { - return - } - - PurchaseManager.shared.completeUnfinishedTransactions { serviceStatus, _ in - guard let viewController = UIApplication.topViewController() else { - return - } - - if let serviceStatus = serviceStatus { - viewController.showSubscriptionActivatedAlert(serviceStatus: serviceStatus) - } - } - } - private func listenTransactionUpdates() { - PurchaseManager.shared.listenTransactionUpdates { serviceStatus, _ in + PurchaseManager.shared.listenTransactionUpdates { serviceStatus, error in guard let viewController = UIApplication.topViewController() else { return } + + if let error = error { + viewController.showErrorAlert(title: "Error", message: error.message) + } if let serviceStatus = serviceStatus { viewController.showSubscriptionActivatedAlert(serviceStatus: serviceStatus) @@ -308,7 +296,6 @@ extension AppDelegate: UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { evaluateUITests() registerUserDefaults() - finishIncompletePurchases() listenTransactionUpdates() createLogFiles() resetLastPingTimestamp() From d5bbb9a63769d70a736f0affa57105971a6da708 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Thu, 14 Dec 2023 17:19:04 +0100 Subject: [PATCH 23/43] refactor(payments): update PurchaseManager --- IVPNClient/Managers/PurchaseManager.swift | 42 +++++++---------------- 1 file changed, 13 insertions(+), 29 deletions(-) diff --git a/IVPNClient/Managers/PurchaseManager.swift b/IVPNClient/Managers/PurchaseManager.swift index a7681b47d..de1eb953c 100644 --- a/IVPNClient/Managers/PurchaseManager.swift +++ b/IVPNClient/Managers/PurchaseManager.swift @@ -73,21 +73,21 @@ class PurchaseManager: NSObject { switch result { case let .success(.verified(transaction)): // Successful purchase - log(.info, message: "Purchase \(productId): success") + log(.info, message: "[Store] Purchase \(productId): success") return transaction case .success(.unverified(_, _)): // Successful purchase but transaction/receipt can't be verified // Could be a jailbroken phone - log(.info, message: "Purchase \(productId): success, unverified") + log(.info, message: "[Store] Purchase \(productId): success, unverified") break case .pending: // Transaction waiting on SCA (Strong Customer Authentication) or // approval from Ask to Buy - log(.info, message: "Purchase \(productId): pending") + log(.info, message: "[Store] Purchase \(productId): pending") break case .userCancelled: // ^^^ - log(.info, message: "Purchase \(productId): userCancelled") + log(.info, message: "[Store] Purchase \(productId): userCancelled") break @unknown default: break @@ -104,24 +104,7 @@ class PurchaseManager: NSObject { } if transaction.revocationDate == nil { - log(.info, message: "Completing updated transaction.") - complete(transaction) { serviceStatus, error in - completion(serviceStatus, error) - } - } - } - } - } - - func completeUnfinishedTransactions(completion: @escaping (ServiceStatus?, ErrorResult?) -> Void) { - Task { - for await result in Transaction.unfinished { - guard case .verified(let transaction) = result else { - continue - } - - if transaction.revocationDate == nil { - log(.info, message: "Completing unfinished transaction.") + log(.info, message: "[Store] Completing updated transaction.") complete(transaction) { serviceStatus, error in completion(serviceStatus, error) } @@ -139,7 +122,7 @@ class PurchaseManager: NSObject { if transaction.revocationDate == nil { self.getAccountFor(transaction: transaction) { account, error in - log(.info, message: "Purchase is restored.") + log(.info, message: "[Store] Purchase is restored.") completion(account, error) return } @@ -147,7 +130,7 @@ class PurchaseManager: NSObject { } let error = ErrorResult(status: 500, message: "There are no purchases to restore.") - log(.error, message: "There are no purchases to restore.") + log(.error, message: "[Store] There are no purchases to restore.") completion(nil, error) } } @@ -169,10 +152,10 @@ class PurchaseManager: NSObject { Application.shared.serviceStatus = sessionStatus.serviceStatus self.finishTransaction(transaction) completion(sessionStatus.serviceStatus, nil) - log(.info, message: "Purchase was successfully finished.") + log(.info, message: "[Store] Purchase was successfully finished.") case .failure(let error): completion(nil, error ?? defaultError) - log(.error, message: "There was an error with purchase completion: \(error?.message ?? "")") + log(.error, message: "[Store] There was an error with purchase completion: \(error?.message ?? "")") } } } @@ -200,10 +183,10 @@ class PurchaseManager: NSObject { self.finishTransaction(transaction) KeyChain.username = account.accountId completion(account, nil) - log(.info, message: "Purchase was successfully restored.") + log(.info, message: "[Store] Purchase was successfully restored.") case .failure(let error): completion(nil, error ?? defaultError) - log(.error, message: "There was an error with purchase completion: \(error?.message ?? "")") + log(.error, message: "[Store] There was an error with purchase completion: \(error?.message ?? "")") } } } @@ -215,10 +198,11 @@ class PurchaseManager: NSObject { return receiptData.base64EncodedString(options: []) } catch { - log(.error, message: "Couldn't read receipt data with error: \(error.localizedDescription)") + log(.error, message: "[Store] Couldn't read receipt with error: \(error.localizedDescription)") } } + log(.error, message: "[Store] Couldn't read receipt") return nil } From cccd23c5816fd3df1af05ec83ae388c25c86b969 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Fri, 15 Dec 2023 10:48:13 +0100 Subject: [PATCH 24/43] refactor(payments): update PaymentViewController --- IVPNClient/Managers/PurchaseManager.swift | 8 ++++---- .../Payment/PaymentViewController.swift | 19 +++++++++++++++++-- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/IVPNClient/Managers/PurchaseManager.swift b/IVPNClient/Managers/PurchaseManager.swift index de1eb953c..9c87b0569 100644 --- a/IVPNClient/Managers/PurchaseManager.swift +++ b/IVPNClient/Managers/PurchaseManager.swift @@ -63,7 +63,7 @@ class PurchaseManager: NSObject { return nil } - func purchase(_ productId: String) async throws -> Transaction? { + func purchase(_ productId: String) async throws -> Product.PurchaseResult? { guard let product = getProduct(id: productId) else { return nil } @@ -71,10 +71,10 @@ class PurchaseManager: NSObject { let result = try await product.purchase() switch result { - case let .success(.verified(transaction)): + case .success(.verified(_)): // Successful purchase log(.info, message: "[Store] Purchase \(productId): success") - return transaction + break case .success(.unverified(_, _)): // Successful purchase but transaction/receipt can't be verified // Could be a jailbroken phone @@ -93,7 +93,7 @@ class PurchaseManager: NSObject { break } - return nil + return result } func listenTransactionUpdates(completion: @escaping (ServiceStatus?, ErrorResult?) -> Void) { diff --git a/IVPNClient/Scenes/Signup/Payment/PaymentViewController.swift b/IVPNClient/Scenes/Signup/Payment/PaymentViewController.swift index a91c34655..787bf4ccb 100644 --- a/IVPNClient/Scenes/Signup/Payment/PaymentViewController.swift +++ b/IVPNClient/Scenes/Signup/Payment/PaymentViewController.swift @@ -205,9 +205,24 @@ class PaymentViewController: UITableViewController { hud.show(in: (navigationController?.view)!) do { - if let transaction = try await PurchaseManager.shared.purchase(identifier) { - completePurchase(transaction: transaction) + if let result = try await PurchaseManager.shared.purchase(identifier) { + switch result { + case let .success(.verified(transaction)): + completePurchase(transaction: transaction) + return + case .success(.unverified(_, _)): + showErrorAlert(title: "Error", message: "Payment failed verification checks") + case .pending: + showAlert(title: "Pending payment", message: "Payment is pending for approval. We will complete the transaction as soon as payment is approved.") + return + case .userCancelled: + break + @unknown default: + break + } } + + hud.dismiss() } catch { showErrorAlert(title: "Error", message: error.localizedDescription) hud.dismiss() From 2945b8e5adace4f7738271829bc36f72999e7f9b Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Fri, 15 Dec 2023 13:05:23 +0100 Subject: [PATCH 25/43] refactor(payments): create Store.storekit --- IVPNClient.xcodeproj/project.pbxproj | 4 + .../xcschemes/IVPNClient.xcscheme | 3 + IVPNClient/Store.storekit | 385 ++++++++++++++++++ 3 files changed, 392 insertions(+) create mode 100644 IVPNClient/Store.storekit diff --git a/IVPNClient.xcodeproj/project.pbxproj b/IVPNClient.xcodeproj/project.pbxproj index 6a02d59e1..156e92985 100644 --- a/IVPNClient.xcodeproj/project.pbxproj +++ b/IVPNClient.xcodeproj/project.pbxproj @@ -109,6 +109,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 */; }; @@ -549,6 +550,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 = ""; }; @@ -1657,6 +1659,7 @@ 82FF0D4123153D1000440E5D /* Colors.xcassets */, 9CB2CE261DAA6C1B007A4D2D /* IVPNClient.entitlements */, 9CB2CE321DAF9283007A4D2D /* Model.xcdatamodeld */, + 825443972B2A1B8F00D77095 /* Store.storekit */, ); path = IVPNClient; sourceTree = ""; @@ -1996,6 +1999,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; 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"> + + Date: Fri, 12 Jan 2024 10:41:23 +0100 Subject: [PATCH 26/43] refactor(payments): update PurchaseManager --- IVPNClient/Managers/PurchaseManager.swift | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/IVPNClient/Managers/PurchaseManager.swift b/IVPNClient/Managers/PurchaseManager.swift index 9c87b0569..64beed0a6 100644 --- a/IVPNClient/Managers/PurchaseManager.swift +++ b/IVPNClient/Managers/PurchaseManager.swift @@ -170,7 +170,7 @@ class PurchaseManager: NSObject { 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() else { + guard let params = restorePurchaseParams(transaction) else { completion(nil, defaultError) return } @@ -249,12 +249,9 @@ class PurchaseManager: NSObject { } } - private func restorePurchaseParams() -> [URLQueryItem]? { - guard let receipt = base64receipt() else { - return nil - } - - return [URLQueryItem(name: "receipt", value: receipt)] + private func restorePurchaseParams(_ transaction: Transaction) -> [URLQueryItem]? { + let transactionId = transaction.id.formatted() + return [URLQueryItem(name: "transactionId", value: transactionId)] } } From e2a3df17596263c44f7428b3381a27734b3761c9 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Mon, 15 Jan 2024 15:05:05 +0100 Subject: [PATCH 27/43] refactor(payments): update PurchaseManager --- IVPNClient/Config/Config.swift | 6 ++--- IVPNClient/Managers/PurchaseManager.swift | 30 +++-------------------- 2 files changed, 7 insertions(+), 29 deletions(-) diff --git a/IVPNClient/Config/Config.swift b/IVPNClient/Config/Config.swift index 59d11fb73..0028f53cc 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 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" - static let apiPaymentRestore = "/v4/account/payment/ios/restore" static let urlTypeLogin = "login" static let urlTypeConnect = "connect" diff --git a/IVPNClient/Managers/PurchaseManager.swift b/IVPNClient/Managers/PurchaseManager.swift index 64beed0a6..d2324933a 100644 --- a/IVPNClient/Managers/PurchaseManager.swift +++ b/IVPNClient/Managers/PurchaseManager.swift @@ -180,7 +180,6 @@ class PurchaseManager: NSObject { ApiService.shared.requestCustomError(request) { (result: ResultCustomError) in switch result { case .success(let account): - self.finishTransaction(transaction) KeyChain.username = account.accountId completion(account, nil) log(.info, message: "[Store] Purchase was successfully restored.") @@ -191,27 +190,9 @@ class PurchaseManager: NSObject { } } - private func base64receipt() -> String? { - if let receiptURL = Bundle.main.appStoreReceiptURL, FileManager.default.fileExists(atPath: receiptURL.path) { - do { - let receiptData = try Data(contentsOf: receiptURL, options: .alwaysMapped) - return receiptData.base64EncodedString(options: []) - } - catch { - log(.error, message: "[Store] Couldn't read receipt with error: \(error.localizedDescription)") - } - } - - log(.error, message: "[Store] Couldn't read receipt") - return nil - } - private func purchaseParams(transaction: Transaction, endpoint: String) -> [URLQueryItem]? { let productId = transaction.productID let transactionId = transaction.id.formatted() - guard let receipt = base64receipt() else { - return nil - } switch endpoint { case Config.apiPaymentInitial: @@ -221,8 +202,7 @@ class PurchaseManager: NSObject { return [ URLQueryItem(name: "account_id", value: tempUsername), URLQueryItem(name: "product_id", value: productId), - URLQueryItem(name: "transaction_id", value: transactionId), - URLQueryItem(name: "receipt", value: receipt) + URLQueryItem(name: "transaction_id", value: transactionId) ] case Config.apiPaymentAdd: guard let sessionToken = KeyChain.sessionToken else { @@ -231,8 +211,7 @@ class PurchaseManager: NSObject { return [ URLQueryItem(name: "session_token", value: sessionToken), URLQueryItem(name: "product_id", value: productId), - URLQueryItem(name: "transaction_id", value: transactionId), - URLQueryItem(name: "receipt", value: receipt) + URLQueryItem(name: "transaction_id", value: transactionId) ] case Config.apiPaymentAddLegacy: guard let username = KeyChain.username else { @@ -241,8 +220,7 @@ class PurchaseManager: NSObject { return [ URLQueryItem(name: "username", value: username), URLQueryItem(name: "productId", value: productId), - URLQueryItem(name: "transactionId", value: transactionId), - URLQueryItem(name: "receiptData", value: receipt) + URLQueryItem(name: "transactionId", value: transactionId) ] default: return nil @@ -251,7 +229,7 @@ class PurchaseManager: NSObject { private func restorePurchaseParams(_ transaction: Transaction) -> [URLQueryItem]? { let transactionId = transaction.id.formatted() - return [URLQueryItem(name: "transactionId", value: transactionId)] + return [URLQueryItem(name: "transaction_id", value: transactionId)] } } From 18e2b87265e868d7a9a14fb855dbfc4ef75b8ef1 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Mon, 15 Jan 2024 15:05:34 +0100 Subject: [PATCH 28/43] refactor(payments): update LoginViewController --- IVPNClient/Scenes/Signup/LoginViewController.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/IVPNClient/Scenes/Signup/LoginViewController.swift b/IVPNClient/Scenes/Signup/LoginViewController.swift index 94cdff23c..255736621 100644 --- a/IVPNClient/Scenes/Signup/LoginViewController.swift +++ b/IVPNClient/Scenes/Signup/LoginViewController.swift @@ -106,7 +106,9 @@ class LoginViewController: UIViewController { self.hud.dismiss() if let error = error { - self.showErrorAlert(title: "Restore failed", message: error.message) + DispatchQueue.main.async { + self.showErrorAlert(title: "Restore failed", message: error.message) + } return } From 47b51aec74b0f45e3f5e3cccaf9cfebaa5077039 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Mon, 15 Jan 2024 16:14:06 +0100 Subject: [PATCH 29/43] refactor(payments): update AppDelegate --- IVPNClient/AppDelegate.swift | 20 +++++++++-------- IVPNClient/Managers/PurchaseManager.swift | 1 + .../Scenes/Signup/LoginViewController.swift | 22 ++++++++++--------- 3 files changed, 24 insertions(+), 19 deletions(-) diff --git a/IVPNClient/AppDelegate.swift b/IVPNClient/AppDelegate.swift index 547c193de..5b949f994 100644 --- a/IVPNClient/AppDelegate.swift +++ b/IVPNClient/AppDelegate.swift @@ -82,16 +82,18 @@ class AppDelegate: UIResponder { private func listenTransactionUpdates() { PurchaseManager.shared.listenTransactionUpdates { serviceStatus, error in - guard let viewController = UIApplication.topViewController() else { - return - } - - if let error = error { - viewController.showErrorAlert(title: "Error", message: error.message) - } + DispatchQueue.main.async { + guard let viewController = UIApplication.topViewController() else { + return + } + + if let error = error { + viewController.showErrorAlert(title: "Error", message: error.message) + } - if let serviceStatus = serviceStatus { - viewController.showSubscriptionActivatedAlert(serviceStatus: serviceStatus) + if let serviceStatus = serviceStatus { + viewController.showSubscriptionActivatedAlert(serviceStatus: serviceStatus) + } } } } diff --git a/IVPNClient/Managers/PurchaseManager.swift b/IVPNClient/Managers/PurchaseManager.swift index d2324933a..9fc540692 100644 --- a/IVPNClient/Managers/PurchaseManager.swift +++ b/IVPNClient/Managers/PurchaseManager.swift @@ -126,6 +126,7 @@ class PurchaseManager: NSObject { completion(account, error) return } + break } } diff --git a/IVPNClient/Scenes/Signup/LoginViewController.swift b/IVPNClient/Scenes/Signup/LoginViewController.swift index 255736621..207773c8c 100644 --- a/IVPNClient/Scenes/Signup/LoginViewController.swift +++ b/IVPNClient/Scenes/Signup/LoginViewController.swift @@ -103,18 +103,20 @@ class LoginViewController: UIViewController { hud.show(in: (navigationController?.view)!) PurchaseManager.shared.restorePurchases { account, error in - self.hud.dismiss() - - if let error = error { - DispatchQueue.main.async { + 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() } - return - } - - if let account = account { - self.userName.text = account.accountId - self.sessionManager.createSession() } } } From a64c7f0a5430bae4fe07b8c0f50b36c611ad7812 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Wed, 24 Jan 2024 15:08:12 +0100 Subject: [PATCH 30/43] refactor(payments): update Config.swift --- IVPNClient/Config/Config.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/IVPNClient/Config/Config.swift b/IVPNClient/Config/Config.swift index 0028f53cc..935b4a9b3 100644 --- a/IVPNClient/Config/Config.swift +++ b/IVPNClient/Config/Config.swift @@ -40,7 +40,7 @@ struct Config { 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" + static let apiPaymentAddLegacy = "/v2/mobile/ios/subscription-purchased-v2" static let urlTypeLogin = "login" static let urlTypeConnect = "connect" From 2b1b3b1a107086002b3d48863342f1132928f4d5 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Wed, 24 Jan 2024 15:16:34 +0100 Subject: [PATCH 31/43] refactor: update LoginViewController --- IVPNClient/Scenes/Signup/LoginViewController.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/IVPNClient/Scenes/Signup/LoginViewController.swift b/IVPNClient/Scenes/Signup/LoginViewController.swift index 207773c8c..e6bb74a2f 100644 --- a/IVPNClient/Scenes/Signup/LoginViewController.swift +++ b/IVPNClient/Scenes/Signup/LoginViewController.swift @@ -107,9 +107,7 @@ class LoginViewController: UIViewController { self.hud.dismiss() if let error = error { - self.showErrorAlert(title: "Restore failed", message: error.message) - return } From fecfc786215f4934c6f717fc8ba31bbe5dbf32b8 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Thu, 25 Jan 2024 10:06:05 +0100 Subject: [PATCH 32/43] refactor(payments): update PurchaseManager --- IVPNClient/Managers/PurchaseManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/IVPNClient/Managers/PurchaseManager.swift b/IVPNClient/Managers/PurchaseManager.swift index 9fc540692..afeb83491 100644 --- a/IVPNClient/Managers/PurchaseManager.swift +++ b/IVPNClient/Managers/PurchaseManager.swift @@ -186,7 +186,7 @@ class PurchaseManager: NSObject { log(.info, message: "[Store] Purchase was successfully restored.") case .failure(let error): completion(nil, error ?? defaultError) - log(.error, message: "[Store] There was an error with purchase completion: \(error?.message ?? "")") + log(.error, message: "[Store] There was an error with restoring purchase: \(error?.message ?? "")") } } } From 054547e84080fa0e2658f99970a2b0a265a06229 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Tue, 30 Jan 2024 11:25:07 +0100 Subject: [PATCH 33/43] refactor(payments): update PurchaseManager --- IVPNClient/Managers/PurchaseManager.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/IVPNClient/Managers/PurchaseManager.swift b/IVPNClient/Managers/PurchaseManager.swift index afeb83491..5f26d5717 100644 --- a/IVPNClient/Managers/PurchaseManager.swift +++ b/IVPNClient/Managers/PurchaseManager.swift @@ -193,7 +193,7 @@ class PurchaseManager: NSObject { private func purchaseParams(transaction: Transaction, endpoint: String) -> [URLQueryItem]? { let productId = transaction.productID - let transactionId = transaction.id.formatted() + let transactionId = String(transaction.id) switch endpoint { case Config.apiPaymentInitial: @@ -229,7 +229,7 @@ class PurchaseManager: NSObject { } private func restorePurchaseParams(_ transaction: Transaction) -> [URLQueryItem]? { - let transactionId = transaction.id.formatted() + let transactionId = String(transaction.id) return [URLQueryItem(name: "transaction_id", value: transactionId)] } From 71088015c6dcf9e17dc516716c9da06051cb1071 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Tue, 30 Jan 2024 14:08:25 +0100 Subject: [PATCH 34/43] refactor(payments): update PurchaseManager --- IVPNClient/Managers/PurchaseManager.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/IVPNClient/Managers/PurchaseManager.swift b/IVPNClient/Managers/PurchaseManager.swift index 5f26d5717..be4cfeccc 100644 --- a/IVPNClient/Managers/PurchaseManager.swift +++ b/IVPNClient/Managers/PurchaseManager.swift @@ -124,9 +124,8 @@ class PurchaseManager: NSObject { self.getAccountFor(transaction: transaction) { account, error in log(.info, message: "[Store] Purchase is restored.") completion(account, error) - return } - break + return } } From f74c0857f72492b6794c9d449fd89f3a1ca54d11 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Tue, 30 Jan 2024 14:24:28 +0100 Subject: [PATCH 35/43] refactor(payments): update PaymentViewController --- IVPNClient/Scenes/Signup/Payment/PaymentViewController.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/IVPNClient/Scenes/Signup/Payment/PaymentViewController.swift b/IVPNClient/Scenes/Signup/Payment/PaymentViewController.swift index 787bf4ccb..bab78ce30 100644 --- a/IVPNClient/Scenes/Signup/Payment/PaymentViewController.swift +++ b/IVPNClient/Scenes/Signup/Payment/PaymentViewController.swift @@ -214,7 +214,6 @@ class PaymentViewController: UITableViewController { showErrorAlert(title: "Error", message: "Payment failed verification checks") case .pending: showAlert(title: "Pending payment", message: "Payment is pending for approval. We will complete the transaction as soon as payment is approved.") - return case .userCancelled: break @unknown default: From 3f04a181f008e621fa29700bad6903044a1d448e Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Tue, 20 Feb 2024 19:09:40 +0100 Subject: [PATCH 36/43] refactor(payments): update PurchaseManager --- IVPNClient/AppDelegate.swift | 19 -------------- IVPNClient/Managers/PurchaseManager.swift | 12 ++++++--- .../MainScreen/MainViewController.swift | 25 +++++++++++++++++++ .../Payment/PaymentViewController.swift | 4 +++ .../Extensions/NotificationName+Ext.swift | 2 ++ 5 files changed, 39 insertions(+), 23 deletions(-) diff --git a/IVPNClient/AppDelegate.swift b/IVPNClient/AppDelegate.swift index 5b949f994..e74ced6f5 100644 --- a/IVPNClient/AppDelegate.swift +++ b/IVPNClient/AppDelegate.swift @@ -80,24 +80,6 @@ class AppDelegate: UIResponder { FileSystemManager.createLogFiles() } - private func listenTransactionUpdates() { - PurchaseManager.shared.listenTransactionUpdates { serviceStatus, error in - DispatchQueue.main.async { - guard let viewController = UIApplication.topViewController() else { - return - } - - if let error = error { - viewController.showErrorAlert(title: "Error", message: error.message) - } - - if let serviceStatus = serviceStatus { - viewController.showSubscriptionActivatedAlert(serviceStatus: serviceStatus) - } - } - } - } - private func resetLastPingTimestamp() { UserDefaults.shared.set(0, forKey: "LastPingTimestamp") } @@ -298,7 +280,6 @@ extension AppDelegate: UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { evaluateUITests() registerUserDefaults() - listenTransactionUpdates() createLogFiles() resetLastPingTimestamp() clearURLCache() diff --git a/IVPNClient/Managers/PurchaseManager.swift b/IVPNClient/Managers/PurchaseManager.swift index be4cfeccc..8b9db7f2f 100644 --- a/IVPNClient/Managers/PurchaseManager.swift +++ b/IVPNClient/Managers/PurchaseManager.swift @@ -33,7 +33,7 @@ class PurchaseManager: NSObject { return SKPaymentQueue.canMakePayments() } - var updateListenerTask: Task? = nil + var observerTask: Task? = nil private(set) var products: [Product] = [] @@ -46,7 +46,7 @@ class PurchaseManager: NSObject { } deinit { - updateListenerTask?.cancel() + observerTask?.cancel() } // MARK: - Methods - @@ -96,8 +96,8 @@ class PurchaseManager: NSObject { return result } - func listenTransactionUpdates(completion: @escaping (ServiceStatus?, ErrorResult?) -> Void) { - updateListenerTask = Task { + func startObserver(completion: @escaping (ServiceStatus?, ErrorResult?) -> Void) { + observerTask = Task { for await result in Transaction.updates { guard case .verified(let transaction) = result else { continue @@ -113,6 +113,10 @@ class PurchaseManager: NSObject { } } + func stopObserver() { + observerTask?.cancel() + } + func restorePurchases(completion: @escaping (Account?, ErrorResult?) -> Void) { Task { for await result in Transaction.currentEntitlements { diff --git a/IVPNClient/Scenes/MainScreen/MainViewController.swift b/IVPNClient/Scenes/MainScreen/MainViewController.swift index efaa9804d..b9e236f1a 100644 --- a/IVPNClient/Scenes/MainScreen/MainViewController.swift +++ b/IVPNClient/Scenes/MainScreen/MainViewController.swift @@ -71,6 +71,7 @@ class MainViewController: UIViewController { addObservers() startAPIUpdate() startVPNStatusObserver() + startPurchaseObserver() } override func viewWillAppear(_ animated: Bool) { @@ -198,6 +199,8 @@ class MainViewController: UIViewController { NotificationCenter.default.addObserver(self, selector: #selector(vpnConfigurationDisabled), name: Notification.Name.VPNConfigurationDisabled, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(subscriptionActivated), name: Notification.Name.SubscriptionActivated, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(updateGeoLocation), name: Notification.Name.UpdateGeoLocation, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(startPurchaseObserver), name: Notification.Name.StartPurchaseObserver, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(stopPurchaseObserver), name: Notification.Name.StopPurchaseObserver, object: nil) } // MARK: - Private methods - @@ -236,6 +239,28 @@ class MainViewController: UIViewController { mainView.updateInfoAlert() } + @objc private func startPurchaseObserver() { + PurchaseManager.shared.startObserver { serviceStatus, error in + DispatchQueue.main.async { + guard let viewController = UIApplication.topViewController() else { + return + } + + if let error = error { + viewController.showErrorAlert(title: "Error", message: error.message) + } + + if let serviceStatus = serviceStatus { + viewController.showSubscriptionActivatedAlert(serviceStatus: serviceStatus) + } + } + } + } + + @objc private func stopPurchaseObserver() { + PurchaseManager.shared.stopObserver() + } + private func initFloatingPanel() { floatingPanel = FloatingPanelController() floatingPanel.setup() diff --git a/IVPNClient/Scenes/Signup/Payment/PaymentViewController.swift b/IVPNClient/Scenes/Signup/Payment/PaymentViewController.swift index bab78ce30..fc6f08939 100644 --- a/IVPNClient/Scenes/Signup/Payment/PaymentViewController.swift +++ b/IVPNClient/Scenes/Signup/Payment/PaymentViewController.swift @@ -200,6 +200,7 @@ class PaymentViewController: UITableViewController { return } + NotificationCenter.default.post(name: Notification.Name.StopPurchaseObserver, object: nil) hud.indicatorView = JGProgressHUDIndeterminateIndicatorView() hud.detailTextLabel.text = "Processing payment..." hud.show(in: (navigationController?.view)!) @@ -221,9 +222,11 @@ class PaymentViewController: UITableViewController { } } + NotificationCenter.default.post(name: Notification.Name.StartPurchaseObserver, object: nil) hud.dismiss() } catch { showErrorAlert(title: "Error", message: error.localizedDescription) + NotificationCenter.default.post(name: Notification.Name.StartPurchaseObserver, object: nil) hud.dismiss() } } @@ -242,6 +245,7 @@ class PaymentViewController: UITableViewController { self.navigationController?.dismiss(animated: true, completion: nil) } } + NotificationCenter.default.post(name: Notification.Name.StartPurchaseObserver, object: nil) return } diff --git a/IVPNClient/Utilities/Extensions/NotificationName+Ext.swift b/IVPNClient/Utilities/Extensions/NotificationName+Ext.swift index dd63512ec..14fe76ba6 100644 --- a/IVPNClient/Utilities/Extensions/NotificationName+Ext.swift +++ b/IVPNClient/Utilities/Extensions/NotificationName+Ext.swift @@ -58,5 +58,7 @@ extension Notification.Name { public static let CustomDNSUpdated = Notification.Name("customDNSUpdatedUpdated") public static let EvaluateReconnect = Notification.Name("evaluateReconnect") public static let EvaluatePlanUpdate = Notification.Name("evaluatePlanUpdate") + public static let StartPurchaseObserver = Notification.Name("startPurchaseObserver") + public static let StopPurchaseObserver = Notification.Name("stopPurchaseObserver") } From 7fd9f6bca3c0a18c7f3107b2005f098a84d32e8c Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Wed, 21 Feb 2024 13:17:02 +0100 Subject: [PATCH 37/43] refactor(payments): add PurchaseManagerDelegate --- IVPNClient/AppDelegate.swift | 50 +++++++ IVPNClient/Managers/PurchaseManager.swift | 37 +++-- IVPNClient/Models/ServiceStatus.swift | 10 +- .../MainScreen/MainViewController.swift | 25 ---- .../Payment/PaymentViewController.swift | 129 ++++++++++-------- .../Extensions/NotificationName+Ext.swift | 2 - 6 files changed, 153 insertions(+), 100 deletions(-) diff --git a/IVPNClient/AppDelegate.swift b/IVPNClient/AppDelegate.swift index e74ced6f5..281aeedce 100644 --- a/IVPNClient/AppDelegate.swift +++ b/IVPNClient/AppDelegate.swift @@ -270,6 +270,11 @@ class AppDelegate: UIResponder { } } } + + private func startPurchaseObserver() { + PurchaseManager.shared.delegate = self + PurchaseManager.shared.startObserver() + } } @@ -283,6 +288,7 @@ extension AppDelegate: UIApplicationDelegate { createLogFiles() resetLastPingTimestamp() clearURLCache() + startPurchaseObserver() DNSManager.shared.loadProfile { _ in } return true @@ -387,3 +393,47 @@ 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(service: Any?) { + DispatchQueue.main.async { + guard let viewController = UIApplication.topViewController() else { + return + } + + if let service = service as? ServiceStatus { + viewController.showSubscriptionActivatedAlert(serviceStatus: service) + } + } + } + + 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/Managers/PurchaseManager.swift b/IVPNClient/Managers/PurchaseManager.swift index 8b9db7f2f..e328266c8 100644 --- a/IVPNClient/Managers/PurchaseManager.swift +++ b/IVPNClient/Managers/PurchaseManager.swift @@ -23,12 +23,21 @@ import StoreKit +@objc protocol PurchaseManagerDelegate: AnyObject { + func purchaseStart() + func purchasePending() + func purchaseSuccess(service: Any?) + func purchaseError(error: Any?) +} + class PurchaseManager: NSObject { // MARK: - Properties - static let shared = PurchaseManager() + weak var delegate: PurchaseManagerDelegate? + var canMakePurchases: Bool { return SKPaymentQueue.canMakePayments() } @@ -46,7 +55,7 @@ class PurchaseManager: NSObject { } deinit { - observerTask?.cancel() + stopObserver() } // MARK: - Methods - @@ -68,26 +77,31 @@ class PurchaseManager: NSObject { return nil } + delegate?.purchaseStart() let result = try await product.purchase() switch result { - case .success(.verified(_)): + case let .success(.verified(transaction)): // Successful purchase - log(.info, message: "[Store] Purchase \(productId): success") + log(.info, message: "[Store] Completing in-app transaction \(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 @@ -96,7 +110,7 @@ class PurchaseManager: NSObject { return result } - func startObserver(completion: @escaping (ServiceStatus?, ErrorResult?) -> Void) { + func startObserver() { observerTask = Task { for await result in Transaction.updates { guard case .verified(let transaction) = result else { @@ -105,9 +119,7 @@ class PurchaseManager: NSObject { if transaction.revocationDate == nil { log(.info, message: "[Store] Completing updated transaction.") - complete(transaction) { serviceStatus, error in - completion(serviceStatus, error) - } + complete(transaction) } } } @@ -139,12 +151,12 @@ class PurchaseManager: NSObject { } } - func complete(_ transaction: Transaction, completion: @escaping (ServiceStatus?, ErrorResult?) -> Void) { + 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 { - completion(nil, defaultError) + delegate?.purchaseError(error: defaultError) return } @@ -155,10 +167,11 @@ class PurchaseManager: NSObject { case .success(let sessionStatus): Application.shared.serviceStatus = sessionStatus.serviceStatus self.finishTransaction(transaction) - completion(sessionStatus.serviceStatus, nil) - log(.info, message: "[Store] Purchase was successfully finished.") + self.delegate?.purchaseSuccess(service: sessionStatus.serviceStatus) + log(.info, message: "[Store] Purchase was completed successfully.") case .failure(let error): - completion(nil, error ?? defaultError) + self.finishTransaction(transaction) + self.delegate?.purchaseError(error: error ?? defaultError) log(.error, message: "[Store] There was an error with purchase completion: \(error?.message ?? "")") } } diff --git a/IVPNClient/Models/ServiceStatus.swift b/IVPNClient/Models/ServiceStatus.swift index 6d78209cf..20bc89bc5 100644 --- a/IVPNClient/Models/ServiceStatus.swift +++ b/IVPNClient/Models/ServiceStatus.swift @@ -89,7 +89,15 @@ struct ServiceStatus: Codable { } func isNewStyleAccount() -> Bool { - return paymentMethod == "prepaid" + guard let username = KeyChain.username else { + return true + } + + return username.hasPrefix("i-") + } + + static func isNewStyleAccount(username: String) -> Bool { + return username.hasPrefix("i-") } func daysUntilSubscriptionExpiration() -> Int { diff --git a/IVPNClient/Scenes/MainScreen/MainViewController.swift b/IVPNClient/Scenes/MainScreen/MainViewController.swift index b9e236f1a..efaa9804d 100644 --- a/IVPNClient/Scenes/MainScreen/MainViewController.swift +++ b/IVPNClient/Scenes/MainScreen/MainViewController.swift @@ -71,7 +71,6 @@ class MainViewController: UIViewController { addObservers() startAPIUpdate() startVPNStatusObserver() - startPurchaseObserver() } override func viewWillAppear(_ animated: Bool) { @@ -199,8 +198,6 @@ class MainViewController: UIViewController { NotificationCenter.default.addObserver(self, selector: #selector(vpnConfigurationDisabled), name: Notification.Name.VPNConfigurationDisabled, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(subscriptionActivated), name: Notification.Name.SubscriptionActivated, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(updateGeoLocation), name: Notification.Name.UpdateGeoLocation, object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(startPurchaseObserver), name: Notification.Name.StartPurchaseObserver, object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(stopPurchaseObserver), name: Notification.Name.StopPurchaseObserver, object: nil) } // MARK: - Private methods - @@ -239,28 +236,6 @@ class MainViewController: UIViewController { mainView.updateInfoAlert() } - @objc private func startPurchaseObserver() { - PurchaseManager.shared.startObserver { serviceStatus, error in - DispatchQueue.main.async { - guard let viewController = UIApplication.topViewController() else { - return - } - - if let error = error { - viewController.showErrorAlert(title: "Error", message: error.message) - } - - if let serviceStatus = serviceStatus { - viewController.showSubscriptionActivatedAlert(serviceStatus: serviceStatus) - } - } - } - } - - @objc private func stopPurchaseObserver() { - PurchaseManager.shared.stopObserver() - } - private func initFloatingPanel() { floatingPanel = FloatingPanelController() floatingPanel.setup() diff --git a/IVPNClient/Scenes/Signup/Payment/PaymentViewController.swift b/IVPNClient/Scenes/Signup/Payment/PaymentViewController.swift index fc6f08939..d452acb4d 100644 --- a/IVPNClient/Scenes/Signup/Payment/PaymentViewController.swift +++ b/IVPNClient/Scenes/Signup/Payment/PaymentViewController.swift @@ -121,9 +121,17 @@ 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) @@ -200,69 +208,10 @@ class PaymentViewController: UITableViewController { return } - NotificationCenter.default.post(name: Notification.Name.StopPurchaseObserver, object: nil) - hud.indicatorView = JGProgressHUDIndeterminateIndicatorView() - hud.detailTextLabel.text = "Processing payment..." - hud.show(in: (navigationController?.view)!) - do { - if let result = try await PurchaseManager.shared.purchase(identifier) { - switch result { - case let .success(.verified(transaction)): - completePurchase(transaction: transaction) - return - case .success(.unverified(_, _)): - showErrorAlert(title: "Error", message: "Payment failed verification checks") - case .pending: - showAlert(title: "Pending payment", message: "Payment is pending for approval. We will complete the transaction as soon as payment is approved.") - case .userCancelled: - break - @unknown default: - break - } - } - - NotificationCenter.default.post(name: Notification.Name.StartPurchaseObserver, object: nil) - hud.dismiss() + _ = try await PurchaseManager.shared.purchase(identifier) } catch { showErrorAlert(title: "Error", message: error.localizedDescription) - NotificationCenter.default.post(name: Notification.Name.StartPurchaseObserver, object: nil) - hud.dismiss() - } - } - - private func completePurchase(transaction: Transaction) { - PurchaseManager.shared.complete(transaction) { [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) - } - } - NotificationCenter.default.post(name: Notification.Name.StartPurchaseObserver, object: 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) - } - } - } } } @@ -318,6 +267,66 @@ 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(service: Any?) { + DispatchQueue.main.async { [self] in + hud.dismiss() + + guard let service = service as? ServiceStatus else { + return + } + + showSubscriptionActivatedAlert(serviceStatus: service) { + 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/Utilities/Extensions/NotificationName+Ext.swift b/IVPNClient/Utilities/Extensions/NotificationName+Ext.swift index 14fe76ba6..dd63512ec 100644 --- a/IVPNClient/Utilities/Extensions/NotificationName+Ext.swift +++ b/IVPNClient/Utilities/Extensions/NotificationName+Ext.swift @@ -58,7 +58,5 @@ extension Notification.Name { public static let CustomDNSUpdated = Notification.Name("customDNSUpdatedUpdated") public static let EvaluateReconnect = Notification.Name("evaluateReconnect") public static let EvaluatePlanUpdate = Notification.Name("evaluatePlanUpdate") - public static let StartPurchaseObserver = Notification.Name("startPurchaseObserver") - public static let StopPurchaseObserver = Notification.Name("stopPurchaseObserver") } From 469a1fb07f402771e0ff153e36d6aa54edef11aa Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Sun, 25 Feb 2024 10:53:05 +0100 Subject: [PATCH 38/43] refactor(payments): update PurchaseManager --- IVPNClient/Managers/PurchaseManager.swift | 33 +++++++++++------------ 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/IVPNClient/Managers/PurchaseManager.swift b/IVPNClient/Managers/PurchaseManager.swift index e328266c8..5cdfe270c 100644 --- a/IVPNClient/Managers/PurchaseManager.swift +++ b/IVPNClient/Managers/PurchaseManager.swift @@ -83,7 +83,7 @@ class PurchaseManager: NSObject { switch result { case let .success(.verified(transaction)): // Successful purchase - log(.info, message: "[Store] Completing in-app transaction \(productId).") + log(.info, message: "[Store] Purchase success \(productId), completing transaction") self.complete(transaction) break case .success(.unverified(_, _)): @@ -113,14 +113,18 @@ class PurchaseManager: NSObject { func startObserver() { observerTask = Task { for await result in Transaction.updates { - guard case .verified(let transaction) = result else { - continue + for await result in Transaction.unfinished { + guard case .verified(let transaction) = result else { + continue + } + + if transaction.revocationDate == nil { + log(.info, message: "[Store] Completing updated transaction \(transaction.productID)") + complete(transaction) + } } - if transaction.revocationDate == nil { - log(.info, message: "[Store] Completing updated transaction.") - complete(transaction) - } + break } } } @@ -166,11 +170,12 @@ class PurchaseManager: NSObject { switch result { case .success(let sessionStatus): Application.shared.serviceStatus = sessionStatus.serviceStatus - self.finishTransaction(transaction) - self.delegate?.purchaseSuccess(service: sessionStatus.serviceStatus) - log(.info, message: "[Store] Purchase was completed successfully.") + Task { + await transaction.finish() + self.delegate?.purchaseSuccess(service: sessionStatus.serviceStatus) + log(.info, message: "[Store] Purchase was completed successfully.") + } case .failure(let error): - self.finishTransaction(transaction) self.delegate?.purchaseError(error: error ?? defaultError) log(.error, message: "[Store] There was an error with purchase completion: \(error?.message ?? "")") } @@ -179,12 +184,6 @@ class PurchaseManager: NSObject { // MARK: - Private methods - - private func finishTransaction(_ transaction: Transaction) { - Task { - await transaction.finish() - } - } - 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 { From a7ca5e81deaa0c5d659334a6251dc306d0989302 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Sun, 25 Feb 2024 10:54:19 +0100 Subject: [PATCH 39/43] chore: clean up IAPManager.swift --- IVPNClient.xcodeproj/project.pbxproj | 4 - IVPNClient/Managers/IAPManager.swift | 288 --------------------------- 2 files changed, 292 deletions(-) delete mode 100644 IVPNClient/Managers/IAPManager.swift diff --git a/IVPNClient.xcodeproj/project.pbxproj b/IVPNClient.xcodeproj/project.pbxproj index 156e92985..10e32657f 100644 --- a/IVPNClient.xcodeproj/project.pbxproj +++ b/IVPNClient.xcodeproj/project.pbxproj @@ -94,7 +94,6 @@ 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 /* 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 */; }; @@ -526,7 +525,6 @@ 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 /* 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 = ""; }; @@ -1004,7 +1002,6 @@ 82D598BF21A6991B000FABDE /* NetworkManager.swift */, 9CB2CE371DB0F860007A4D2D /* FileSystemManager.swift */, 9C69422D1DD20FC200F9A801 /* KeyChain.swift */, - 8247E1D922686217006C0C08 /* IAPManager.swift */, 820535272302B9D7007BDD58 /* APIAccessManager.swift */, 8223C54D22EAE93F00CD283D /* SessionManager.swift */, 8206F32224367A240056B465 /* VPNErrorObserver.swift */, @@ -2277,7 +2274,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 */, diff --git a/IVPNClient/Managers/IAPManager.swift b/IVPNClient/Managers/IAPManager.swift deleted file mode 100644 index e46a5d2f3..000000000 --- a/IVPNClient/Managers/IAPManager.swift +++ /dev/null @@ -1,288 +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(ProductId.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) ?? "" - } - - // 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)] - } - -} From 7f7ee14959f59803469fe31abb1dbd25919b6b15 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Tue, 5 Mar 2024 16:44:01 +0100 Subject: [PATCH 40/43] refactor(payments): update PurchaseManager --- IVPNClient/Managers/PurchaseManager.swift | 27 ++++++++++++++++------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/IVPNClient/Managers/PurchaseManager.swift b/IVPNClient/Managers/PurchaseManager.swift index 5cdfe270c..257225d46 100644 --- a/IVPNClient/Managers/PurchaseManager.swift +++ b/IVPNClient/Managers/PurchaseManager.swift @@ -46,6 +46,8 @@ class PurchaseManager: NSObject { private(set) var products: [Product] = [] + private var purchasing = false + private var apiEndpoint: String { guard let _ = KeyChain.sessionToken else { return Config.apiPaymentInitial @@ -78,12 +80,13 @@ class PurchaseManager: NSObject { } delegate?.purchaseStart() + purchasing = true let result = try await product.purchase() switch result { case let .success(.verified(transaction)): // Successful purchase - log(.info, message: "[Store] Purchase success \(productId), completing transaction") + log(.info, message: "[Store] Completing successful in-app purchase \(productId)") self.complete(transaction) break case .success(.unverified(_, _)): @@ -91,17 +94,20 @@ class PurchaseManager: NSObject { // Could be a jailbroken phone log(.info, message: "[Store] Purchase \(productId): success, unverified") delegate?.purchaseError(error: ErrorResult(status: 500, message: "Purchase is unverified.")) + purchasing = false 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() + purchasing = false break case .userCancelled: // ^^^ log(.info, message: "[Store] Purchase \(productId): userCancelled") delegate?.purchaseError(error: ErrorResult(status: 500, message: "User canelled the purchase.")) + purchasing = false break @unknown default: break @@ -111,15 +117,19 @@ class PurchaseManager: NSObject { } func startObserver() { - observerTask = Task { - for await result in Transaction.updates { + observerTask = Task(priority: .background) { + for await _ in Transaction.updates { + guard !purchasing else { + break + } + for await result in Transaction.unfinished { guard case .verified(let transaction) = result else { continue } if transaction.revocationDate == nil { - log(.info, message: "[Store] Completing updated transaction \(transaction.productID)") + log(.info, message: "[Store] Completing unfinished purchase \(transaction.productID)") complete(transaction) } } @@ -142,7 +152,6 @@ class PurchaseManager: NSObject { if transaction.revocationDate == nil { self.getAccountFor(transaction: transaction) { account, error in - log(.info, message: "[Store] Purchase is restored.") completion(account, error) } return @@ -150,7 +159,7 @@ class PurchaseManager: NSObject { } let error = ErrorResult(status: 500, message: "There are no purchases to restore.") - log(.error, message: "[Store] There are no purchases to restore.") + log(.error, message: "[Store] There are no purchases to restore") completion(nil, error) } } @@ -173,10 +182,12 @@ class PurchaseManager: NSObject { Task { await transaction.finish() self.delegate?.purchaseSuccess(service: sessionStatus.serviceStatus) - log(.info, message: "[Store] Purchase was completed successfully.") + self.purchasing = false + log(.info, message: "[Store] Purchase \(transaction.productID) completed successfully") } case .failure(let error): self.delegate?.purchaseError(error: error ?? defaultError) + self.purchasing = false log(.error, message: "[Store] There was an error with purchase completion: \(error?.message ?? "")") } } @@ -198,7 +209,7 @@ class PurchaseManager: NSObject { case .success(let account): KeyChain.username = account.accountId completion(account, nil) - log(.info, message: "[Store] Purchase was successfully restored.") + 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 ?? "")") From 3ef3f2640b081fad153da60d826dac83ae8215c9 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Thu, 21 Mar 2024 12:05:03 +0100 Subject: [PATCH 41/43] feat: update PurchaseManager.swift --- IVPNClient/Managers/PurchaseManager.swift | 28 +++++------------------ 1 file changed, 6 insertions(+), 22 deletions(-) diff --git a/IVPNClient/Managers/PurchaseManager.swift b/IVPNClient/Managers/PurchaseManager.swift index 257225d46..27f085f26 100644 --- a/IVPNClient/Managers/PurchaseManager.swift +++ b/IVPNClient/Managers/PurchaseManager.swift @@ -46,8 +46,6 @@ class PurchaseManager: NSObject { private(set) var products: [Product] = [] - private var purchasing = false - private var apiEndpoint: String { guard let _ = KeyChain.sessionToken else { return Config.apiPaymentInitial @@ -80,7 +78,6 @@ class PurchaseManager: NSObject { } delegate?.purchaseStart() - purchasing = true let result = try await product.purchase() switch result { @@ -94,20 +91,17 @@ class PurchaseManager: NSObject { // Could be a jailbroken phone log(.info, message: "[Store] Purchase \(productId): success, unverified") delegate?.purchaseError(error: ErrorResult(status: 500, message: "Purchase is unverified.")) - purchasing = false 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() - purchasing = false break case .userCancelled: // ^^^ log(.info, message: "[Store] Purchase \(productId): userCancelled") delegate?.purchaseError(error: ErrorResult(status: 500, message: "User canelled the purchase.")) - purchasing = false break @unknown default: break @@ -118,23 +112,15 @@ class PurchaseManager: NSObject { func startObserver() { observerTask = Task(priority: .background) { - for await _ in Transaction.updates { - guard !purchasing else { - break + for await result in Transaction.updates { + guard case .verified(let transaction) = result else { + continue } - for await result in Transaction.unfinished { - guard case .verified(let transaction) = result else { - continue - } - - if transaction.revocationDate == nil { - log(.info, message: "[Store] Completing unfinished purchase \(transaction.productID)") - complete(transaction) - } + if transaction.revocationDate == nil { + log(.info, message: "[Store] Completing unfinished purchase \(transaction.productID)") + complete(transaction) } - - break } } } @@ -182,12 +168,10 @@ class PurchaseManager: NSObject { Task { await transaction.finish() self.delegate?.purchaseSuccess(service: sessionStatus.serviceStatus) - self.purchasing = false log(.info, message: "[Store] Purchase \(transaction.productID) completed successfully") } case .failure(let error): self.delegate?.purchaseError(error: error ?? defaultError) - self.purchasing = false log(.error, message: "[Store] There was an error with purchase completion: \(error?.message ?? "")") } } From 6b8a786299c7393b4ff0de2e1bd9eb70cb27a1df Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Thu, 21 Mar 2024 13:19:07 +0100 Subject: [PATCH 42/43] feat: update PurchaseManagerDelegate --- IVPNClient/AppDelegate.swift | 6 +++++- IVPNClient/Enums/ApiResults/SessionStatus.swift | 1 + IVPNClient/Managers/PurchaseManager.swift | 4 ++-- .../Scenes/Signup/Payment/PaymentViewController.swift | 7 ++++++- 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/IVPNClient/AppDelegate.swift b/IVPNClient/AppDelegate.swift index 53597ea4c..138bc205e 100644 --- a/IVPNClient/AppDelegate.swift +++ b/IVPNClient/AppDelegate.swift @@ -412,7 +412,11 @@ extension AppDelegate: PurchaseManagerDelegate { } } - func purchaseSuccess(service: Any?) { + func purchaseSuccess(service: Any?, extended: Bool) { + guard extended else { + return + } + DispatchQueue.main.async { guard let viewController = UIApplication.topViewController() else { return 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/PurchaseManager.swift b/IVPNClient/Managers/PurchaseManager.swift index 27f085f26..3dd53a557 100644 --- a/IVPNClient/Managers/PurchaseManager.swift +++ b/IVPNClient/Managers/PurchaseManager.swift @@ -26,7 +26,7 @@ import StoreKit @objc protocol PurchaseManagerDelegate: AnyObject { func purchaseStart() func purchasePending() - func purchaseSuccess(service: Any?) + func purchaseSuccess(service: Any?, extended: Bool) func purchaseError(error: Any?) } @@ -167,7 +167,7 @@ class PurchaseManager: NSObject { Application.shared.serviceStatus = sessionStatus.serviceStatus Task { await transaction.finish() - self.delegate?.purchaseSuccess(service: sessionStatus.serviceStatus) + self.delegate?.purchaseSuccess(service: sessionStatus.serviceStatus, extended: sessionStatus.extended ?? false) log(.info, message: "[Store] Purchase \(transaction.productID) completed successfully") } case .failure(let error): diff --git a/IVPNClient/Scenes/Signup/Payment/PaymentViewController.swift b/IVPNClient/Scenes/Signup/Payment/PaymentViewController.swift index d452acb4d..23d19fc12 100644 --- a/IVPNClient/Scenes/Signup/Payment/PaymentViewController.swift +++ b/IVPNClient/Scenes/Signup/Payment/PaymentViewController.swift @@ -286,7 +286,12 @@ extension PaymentViewController: PurchaseManagerDelegate { } } - func purchaseSuccess(service: Any?) { + func purchaseSuccess(service: Any?, extended: Bool) { + guard extended else { + hud.dismiss() + return + } + DispatchQueue.main.async { [self] in hud.dismiss() From 1d9df14cf0c03fefccc768da185867dd8db3c0ae Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Tue, 16 Apr 2024 17:49:42 +0200 Subject: [PATCH 43/43] feat: update PurchaseManager.swift --- IVPNClient/AppDelegate.swift | 6 ++---- IVPNClient/Managers/PurchaseManager.swift | 4 ++-- .../Scenes/Signup/Payment/PaymentViewController.swift | 8 ++------ .../Utilities/Extensions/UIViewController+Ext.swift | 4 ++-- 4 files changed, 8 insertions(+), 14 deletions(-) diff --git a/IVPNClient/AppDelegate.swift b/IVPNClient/AppDelegate.swift index 138bc205e..1753b0197 100644 --- a/IVPNClient/AppDelegate.swift +++ b/IVPNClient/AppDelegate.swift @@ -412,7 +412,7 @@ extension AppDelegate: PurchaseManagerDelegate { } } - func purchaseSuccess(service: Any?, extended: Bool) { + func purchaseSuccess(activeUntil: String, extended: Bool) { guard extended else { return } @@ -422,9 +422,7 @@ extension AppDelegate: PurchaseManagerDelegate { return } - if let service = service as? ServiceStatus { - viewController.showSubscriptionActivatedAlert(serviceStatus: service) - } + viewController.showSubscriptionActivatedAlert(activeUntil: activeUntil) } } diff --git a/IVPNClient/Managers/PurchaseManager.swift b/IVPNClient/Managers/PurchaseManager.swift index 3dd53a557..f0fe61c43 100644 --- a/IVPNClient/Managers/PurchaseManager.swift +++ b/IVPNClient/Managers/PurchaseManager.swift @@ -26,7 +26,7 @@ import StoreKit @objc protocol PurchaseManagerDelegate: AnyObject { func purchaseStart() func purchasePending() - func purchaseSuccess(service: Any?, extended: Bool) + func purchaseSuccess(activeUntil: String, extended: Bool) func purchaseError(error: Any?) } @@ -167,7 +167,7 @@ class PurchaseManager: NSObject { Application.shared.serviceStatus = sessionStatus.serviceStatus Task { await transaction.finish() - self.delegate?.purchaseSuccess(service: sessionStatus.serviceStatus, extended: sessionStatus.extended ?? false) + 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): diff --git a/IVPNClient/Scenes/Signup/Payment/PaymentViewController.swift b/IVPNClient/Scenes/Signup/Payment/PaymentViewController.swift index 23d19fc12..396f0dd8f 100644 --- a/IVPNClient/Scenes/Signup/Payment/PaymentViewController.swift +++ b/IVPNClient/Scenes/Signup/Payment/PaymentViewController.swift @@ -286,7 +286,7 @@ extension PaymentViewController: PurchaseManagerDelegate { } } - func purchaseSuccess(service: Any?, extended: Bool) { + func purchaseSuccess(activeUntil: String, extended: Bool) { guard extended else { hud.dismiss() return @@ -295,11 +295,7 @@ extension PaymentViewController: PurchaseManagerDelegate { DispatchQueue.main.async { [self] in hud.dismiss() - guard let service = service as? ServiceStatus else { - return - } - - showSubscriptionActivatedAlert(serviceStatus: service) { + showSubscriptionActivatedAlert(activeUntil: activeUntil) { if KeyChain.sessionToken == nil { KeyChain.username = KeyChain.tempUsername KeyChain.tempUsername = nil diff --git a/IVPNClient/Utilities/Extensions/UIViewController+Ext.swift b/IVPNClient/Utilities/Extensions/UIViewController+Ext.swift index 6abe76475..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()