diff options
| author | Andrej Mihajlov <and@mullvad.net> | 2021-09-15 14:32:24 +0200 |
|---|---|---|
| committer | Andrej Mihajlov <and@mullvad.net> | 2021-09-16 12:57:29 +0200 |
| commit | f9be34013ae82bf4d4a2c605485f66ab14c55b6e (patch) | |
| tree | 4cf489fa45b4a84266916d6ba08a284bc87568ce | |
| parent | 3ee0fdffa3750f4971146f85e136412f20a9326c (diff) | |
| download | mullvadvpn-f9be34013ae82bf4d4a2c605485f66ab14c55b6e.tar.xz mullvadvpn-f9be34013ae82bf4d4a2c605485f66ab14c55b6e.zip | |
AppStorePaymentManager: refactor
10 files changed, 588 insertions, 412 deletions
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index d3cf645deb..843362f450 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -59,9 +59,14 @@ 584592612639B4A200EF967F /* ConsentContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 584592602639B4A200EF967F /* ConsentContentView.swift */; }; 5845F842236CBACD00B2D93C /* PacketTunnelIpc.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5845F841236CBACD00B2D93C /* PacketTunnelIpc.swift */; }; 5845F843236CBDAB00B2D93C /* PacketTunnelIpc.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5845F841236CBACD00B2D93C /* PacketTunnelIpc.swift */; }; + 5846226526E0D9630035F7C2 /* ProductsRequestOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5846226426E0D9630035F7C2 /* ProductsRequestOperation.swift */; }; 5846226726E0DF960035F7C2 /* Promise+OperationQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5846226626E0DF960035F7C2 /* Promise+OperationQueue.swift */; }; 5846226826E0DF960035F7C2 /* Promise+OperationQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5846226626E0DF960035F7C2 /* Promise+OperationQueue.swift */; }; 5846226A26E0E6FA0035F7C2 /* ReceiptRefreshOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5846226926E0E6FA0035F7C2 /* ReceiptRefreshOperation.swift */; }; + 5846227126E229F20035F7C2 /* AppStoreSubscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5846227026E229F20035F7C2 /* AppStoreSubscription.swift */; }; + 5846227326E22A160035F7C2 /* AppStorePaymentObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5846227226E22A160035F7C2 /* AppStorePaymentObserver.swift */; }; + 5846227526E22A350035F7C2 /* AnyAppStorePaymentObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5846227426E22A350035F7C2 /* AnyAppStorePaymentObserver.swift */; }; + 5846227726E22A7C0035F7C2 /* AppStorePaymentManagerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5846227626E22A7C0035F7C2 /* AppStorePaymentManagerDelegate.swift */; }; 584789B8264D4A2A000E45FB /* old_le_root_cert.cer in Resources */ = {isa = PBXBuildFile; fileRef = 584789B4264D4A2A000E45FB /* old_le_root_cert.cer */; }; 584789B9264D4A2A000E45FB /* old_le_root_cert.cer in Resources */ = {isa = PBXBuildFile; fileRef = 584789B4264D4A2A000E45FB /* old_le_root_cert.cer */; }; 584789BE264D4A2A000E45FB /* new_le_root_cert.cer in Resources */ = {isa = PBXBuildFile; fileRef = 584789B7264D4A2A000E45FB /* new_le_root_cert.cer */; }; @@ -261,6 +266,9 @@ 58FAEE0224533ABB00CB0F5B /* KeychainMatchLimit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FAEDFC24533A5500CB0F5B /* KeychainMatchLimit.swift */; }; 58FAEE0324533ABE00CB0F5B /* KeychainReturn.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FAEDFE24533A7000CB0F5B /* KeychainReturn.swift */; }; 58FAEE0424533AC000CB0F5B /* KeychainClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FAEE0024533A9C00CB0F5B /* KeychainClass.swift */; }; + 58FB865526E8BF3100F188BC /* AppStorePaymentManagerError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FB865426E8BF3100F188BC /* AppStorePaymentManagerError.swift */; }; + 58FB865E26EA284E00F188BC /* LogFormatting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FB865D26EA284E00F188BC /* LogFormatting.swift */; }; + 58FB865F26EA2E6D00F188BC /* LogFormatting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FB865D26EA284E00F188BC /* LogFormatting.swift */; }; 58FD5BE724192A2C00112C88 /* AppStoreReceipt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FD5BE624192A2B00112C88 /* AppStoreReceipt.swift */; }; 58FD5BF024238EB300112C88 /* SKProduct+Formatting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FD5BEF24238EB300112C88 /* SKProduct+Formatting.swift */; }; 58FD5BF22424F7D700112C88 /* UserInterfaceInteractionRestriction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FD5BF12424F7D700112C88 /* UserInterfaceInteractionRestriction.swift */; }; @@ -351,8 +359,13 @@ 5840250322B11AB700E4CFEC /* MullvadEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MullvadEndpoint.swift; sourceTree = "<group>"; }; 584592602639B4A200EF967F /* ConsentContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsentContentView.swift; sourceTree = "<group>"; }; 5845F841236CBACD00B2D93C /* PacketTunnelIpc.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketTunnelIpc.swift; sourceTree = "<group>"; }; + 5846226426E0D9630035F7C2 /* ProductsRequestOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductsRequestOperation.swift; sourceTree = "<group>"; }; 5846226626E0DF960035F7C2 /* Promise+OperationQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Promise+OperationQueue.swift"; sourceTree = "<group>"; }; 5846226926E0E6FA0035F7C2 /* ReceiptRefreshOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptRefreshOperation.swift; sourceTree = "<group>"; }; + 5846227026E229F20035F7C2 /* AppStoreSubscription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreSubscription.swift; sourceTree = "<group>"; }; + 5846227226E22A160035F7C2 /* AppStorePaymentObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStorePaymentObserver.swift; sourceTree = "<group>"; }; + 5846227426E22A350035F7C2 /* AnyAppStorePaymentObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyAppStorePaymentObserver.swift; sourceTree = "<group>"; }; + 5846227626E22A7C0035F7C2 /* AppStorePaymentManagerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStorePaymentManagerDelegate.swift; sourceTree = "<group>"; }; 584789B4264D4A2A000E45FB /* old_le_root_cert.cer */ = {isa = PBXFileReference; lastKnownFileType = file; path = old_le_root_cert.cer; sourceTree = "<group>"; }; 584789B7264D4A2A000E45FB /* new_le_root_cert.cer */ = {isa = PBXFileReference; lastKnownFileType = file; path = new_le_root_cert.cer; sourceTree = "<group>"; }; 584789DF26529D72000E45FB /* SSLPinningURLSessionDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSLPinningURLSessionDelegate.swift; sourceTree = "<group>"; }; @@ -488,6 +501,8 @@ 58FAEDFC24533A5500CB0F5B /* KeychainMatchLimit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainMatchLimit.swift; sourceTree = "<group>"; }; 58FAEDFE24533A7000CB0F5B /* KeychainReturn.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainReturn.swift; sourceTree = "<group>"; }; 58FAEE0024533A9C00CB0F5B /* KeychainClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainClass.swift; sourceTree = "<group>"; }; + 58FB865426E8BF3100F188BC /* AppStorePaymentManagerError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStorePaymentManagerError.swift; sourceTree = "<group>"; }; + 58FB865D26EA284E00F188BC /* LogFormatting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogFormatting.swift; sourceTree = "<group>"; }; 58FD5BE624192A2B00112C88 /* AppStoreReceipt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreReceipt.swift; sourceTree = "<group>"; }; 58FD5BEF24238EB300112C88 /* SKProduct+Formatting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SKProduct+Formatting.swift"; sourceTree = "<group>"; }; 58FD5BF12424F7D700112C88 /* UserInterfaceInteractionRestriction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserInterfaceInteractionRestriction.swift; sourceTree = "<group>"; }; @@ -547,6 +562,7 @@ 580EE22324B3243100F9D8A1 /* AsyncBlockOperation.swift */, 58E973DD24850EB600096F90 /* AsyncOperation.swift */, 580EE20524B3222200F9D8A1 /* ExclusivityController.swift */, + 5846226426E0D9630035F7C2 /* ProductsRequestOperation.swift */, 5846226926E0E6FA0035F7C2 /* ReceiptRefreshOperation.swift */, 5820675D26E6839900655B05 /* PresentAlertOperation.swift */, ); @@ -558,6 +574,7 @@ children = ( 581503A224D6F1EC00C9C50E /* ChainedError+Logger.swift */, 5815039624D6ECAE00C9C50E /* CustomFormatLogHandler.swift */, + 58FB865D26EA284E00F188BC /* LogFormatting.swift */, 581503A524D6F4AE00C9C50E /* Logging.swift */, 5815039324D6EB7200C9C50E /* LogRotation.swift */, 5823FA4F26CA690600283BF8 /* OSLogHandler.swift */, @@ -610,6 +627,19 @@ path = REST; sourceTree = "<group>"; }; + 5846226F26E229CD0035F7C2 /* AppStorePaymentManager */ = { + isa = PBXGroup; + children = ( + 5846227426E22A350035F7C2 /* AnyAppStorePaymentObserver.swift */, + 58DF28A42417CB4B00E836B0 /* AppStorePaymentManager.swift */, + 5846227626E22A7C0035F7C2 /* AppStorePaymentManagerDelegate.swift */, + 58FB865426E8BF3100F188BC /* AppStorePaymentManagerError.swift */, + 5846227226E22A160035F7C2 /* AppStorePaymentObserver.swift */, + 5846227026E229F20035F7C2 /* AppStoreSubscription.swift */, + ); + path = AppStorePaymentManager; + sourceTree = "<group>"; + }; 586ADD4323FC13AD00CE9E87 /* GeoJSON */ = { isa = PBXGroup; children = ( @@ -669,6 +699,7 @@ 58CE5E62224146200008646E /* MullvadVPN */ = { isa = PBXGroup; children = ( + 5846226F26E229CD0035F7C2 /* AppStorePaymentManager */, 587AD7C92342283900E93A53 /* Account.swift */, 5896CEF126972DEB00B0FAE8 /* AccountContentView.swift */, 582BB1B42295780F0055B6EF /* AccountExpiry.swift */, @@ -680,7 +711,6 @@ 5868585424054096000B8131 /* AppButton.swift */, 58CE5E63224146200008646E /* AppDelegate.swift */, 58BFA5CB22A7CE1F00A6173D /* ApplicationConfiguration.swift */, - 58DF28A42417CB4B00E836B0 /* AppStorePaymentManager.swift */, 58FD5BE624192A2B00112C88 /* AppStoreReceipt.swift */, 58CE5E6A224146210008646E /* Assets.xcassets */, 58FEEB57260B662E00A621A8 /* AutomaticKeyboardResponder.swift */, @@ -1143,7 +1173,9 @@ 5896CEF226972DEB00B0FAE8 /* AccountContentView.swift in Sources */, 5840250122B1124600E4CFEC /* IPAddress+Codable.swift in Sources */, 5857F24724C882D700CF6F47 /* SelectLocationNavigationController.swift in Sources */, + 5846227126E229F20035F7C2 /* AppStoreSubscription.swift in Sources */, 58E1337126D2BE9C00CC316B /* AnyOptional.swift in Sources */, + 5846226526E0D9630035F7C2 /* ProductsRequestOperation.swift in Sources */, 58BA693123EADA6A009DC256 /* SimulatorTunnelProvider.swift in Sources */, 58E1336D26D2BE7500CC316B /* AnyResult.swift in Sources */, 587B753B2666467500DEF7E9 /* NotificationBannerView.swift in Sources */, @@ -1168,6 +1200,7 @@ 5850367F25A481D800A43E93 /* IPAddressRange+Codable.swift in Sources */, 5871FB96254ADE4E0051A0A4 /* ConsolidatedApplicationLog.swift in Sources */, 58FEEB58260B662E00A621A8 /* AutomaticKeyboardResponder.swift in Sources */, + 5846227326E22A160035F7C2 /* AppStorePaymentObserver.swift in Sources */, 58FAEDEF245069C700CB0F5B /* KeychainAttributes.swift in Sources */, 58CB0EE024B86751001EF0D8 /* RESTClient.swift in Sources */, 5846226A26E0E6FA0035F7C2 /* ReceiptRefreshOperation.swift in Sources */, @@ -1175,6 +1208,7 @@ 58293FB125124117005D0BB5 /* CustomTextField.swift in Sources */, 582AE3102440A6CA00E6733A /* AccountTokenInput.swift in Sources */, 58FAEDF7245088E100CB0F5B /* Keychain.swift in Sources */, + 5846227726E22A7C0035F7C2 /* AppStorePaymentManagerDelegate.swift in Sources */, 5871FB8325498CA20051A0A4 /* Swizzle.swift in Sources */, 58EF581125D69DB400AEBA94 /* StatusImageView.swift in Sources */, 58907D9524D17B4E00CFC3F5 /* DisconnectSplitButton.swift in Sources */, @@ -1188,6 +1222,7 @@ 58AEEF6B2344A46200C9BBD5 /* TunnelSettingsManager.swift in Sources */, 587CBFE322807F530028DED3 /* UIColor+Helpers.swift in Sources */, 58FAEDFD24533A5500CB0F5B /* KeychainMatchLimit.swift in Sources */, + 5846227526E22A350035F7C2 /* AnyAppStorePaymentObserver.swift in Sources */, 585CA70F25F8C44600B47C62 /* UIMetrics.swift in Sources */, 5840250422B11AB700E4CFEC /* MullvadEndpoint.swift in Sources */, 58CC40EF24A601900019D96E /* ObserverList.swift in Sources */, @@ -1235,6 +1270,7 @@ 58AEEF652344A36000C9BBD5 /* KeychainError.swift in Sources */, 581503A624D6F4AE00C9C50E /* Logging.swift in Sources */, 58CCA01222424D11004F3011 /* SettingsViewController.swift in Sources */, + 58FB865526E8BF3100F188BC /* AppStorePaymentManagerError.swift in Sources */, 58FD5BF42428C67600112C88 /* InAppPurchaseButton.swift in Sources */, 580EE22424B3243100F9D8A1 /* AsyncBlockOperation.swift in Sources */, 58293FAE2510CA58005D0BB5 /* ProblemReportViewController.swift in Sources */, @@ -1245,6 +1281,7 @@ 5868BD33261DCD2600E6027F /* CustomSplitViewController.swift in Sources */, 58CCA01E2242787B004F3011 /* AccountTextField.swift in Sources */, 584592612639B4A200EF967F /* ConsentContentView.swift in Sources */, + 58FB865E26EA284E00F188BC /* LogFormatting.swift in Sources */, 587AD7CA2342283900E93A53 /* Account.swift in Sources */, 58F840AF2464382C0044E708 /* KeychainItemRevision.swift in Sources */, 585DA88726B0277200B8C587 /* RESTError.swift in Sources */, @@ -1278,6 +1315,7 @@ 58FAEE0224533ABB00CB0F5B /* KeychainMatchLimit.swift in Sources */, 5860392A26DCE7AB00554C79 /* PromiseCompletion.swift in Sources */, 58FAEE0224533ABB00CB0F5B /* KeychainMatchLimit.swift in Sources */, + 58FB865F26EA2E6D00F188BC /* LogFormatting.swift in Sources */, 58FAEE0324533ABE00CB0F5B /* KeychainReturn.swift in Sources */, 58BFA5CD22A7CE1F00A6173D /* ApplicationConfiguration.swift in Sources */, 5850368D25A49E2200A43E93 /* PrivateKeyWithMetadata.swift in Sources */, diff --git a/ios/MullvadVPN/AppStorePaymentManager.swift b/ios/MullvadVPN/AppStorePaymentManager.swift deleted file mode 100644 index 407b1956f5..0000000000 --- a/ios/MullvadVPN/AppStorePaymentManager.swift +++ /dev/null @@ -1,411 +0,0 @@ -// -// AppStorePaymentManager.swift -// MullvadVPN -// -// Created by pronebird on 10/03/2020. -// Copyright © 2020 Mullvad VPN AB. All rights reserved. -// - -import Foundation -import StoreKit -import Logging - -enum AppStoreSubscription: String { - /// Thirty days non-renewable subscription - case thirtyDays = "net.mullvad.MullvadVPN.subscription.30days" - - var localizedTitle: String { - switch self { - case .thirtyDays: - return NSLocalizedString( - "APPSTORE_SUBSCRIPTION_TITLE_ADD_30_DAYS", - tableName: "AppStoreSubscriptions", - comment: "Title for non-renewable subscription that credits 30 days to user account." - ) - } - } -} - -extension SKProduct { - var customLocalizedTitle: String? { - return AppStoreSubscription(rawValue: productIdentifier)?.localizedTitle - } -} - -extension Set where Element == AppStoreSubscription { - var productIdentifiersSet: Set<String> { - Set<String>(self.map { $0.rawValue }) - } -} - -protocol AppStorePaymentObserver: AnyObject { - func appStorePaymentManager( - _ manager: AppStorePaymentManager, - transaction: SKPaymentTransaction, - accountToken: String?, - didFailWithError error: AppStorePaymentManager.Error) - - func appStorePaymentManager( - _ manager: AppStorePaymentManager, - transaction: SKPaymentTransaction, - accountToken: String, - didFinishWithResponse response: CreateApplePaymentResponse) -} - -/// A type-erasing weak container for `AppStorePaymentObserver` -private class AnyAppStorePaymentObserver: AppStorePaymentObserver, WeakObserverBox, Equatable { - private(set) weak var inner: AppStorePaymentObserver? - - init<T: AppStorePaymentObserver>(_ inner: T) { - self.inner = inner - } - - func appStorePaymentManager(_ manager: AppStorePaymentManager, - transaction: SKPaymentTransaction, - accountToken: String?, - didFailWithError error: AppStorePaymentManager.Error) - { - self.inner?.appStorePaymentManager( - manager, - transaction: transaction, - accountToken: accountToken, - didFailWithError: error) - } - - func appStorePaymentManager(_ manager: AppStorePaymentManager, - transaction: SKPaymentTransaction, - accountToken: String, - didFinishWithResponse response: CreateApplePaymentResponse) - { - self.inner?.appStorePaymentManager( - manager, - transaction: transaction, - accountToken: accountToken, - didFinishWithResponse: response) - } - - static func == (lhs: AnyAppStorePaymentObserver, rhs: AnyAppStorePaymentObserver) -> Bool { - return lhs.inner === rhs.inner - } -} - -protocol AppStorePaymentManagerDelegate: AnyObject { - - /// Return the account token associated with the payment. - /// Usually called for unfinished transactions coming back after the app was restarted. - func appStorePaymentManager(_ manager: AppStorePaymentManager, - didRequestAccountTokenFor payment: SKPayment) -> String? -} - -class AppStorePaymentManager: NSObject, SKPaymentTransactionObserver { - - enum Error: ChainedError { - case noAccountSet - case storePayment(Swift.Error) - case readReceipt(AppStoreReceipt.Error) - case sendReceipt(RestError) - - var errorDescription: String? { - switch self { - case .noAccountSet: - return "Account is not set" - case .storePayment: - return "Store payment error" - case .readReceipt: - return "Read recept error" - case .sendReceipt: - return "Send receipt error" - } - } - } - - private enum ExlcusivityCategory { - case sendReceipt - } - - /// A shared instance of `AppStorePaymentManager` - static let shared = AppStorePaymentManager(queue: SKPaymentQueue.default()) - - private let logger = Logger(label: "AppStorePaymentManager") - - private let operationQueue = OperationQueue() - private lazy var exclusivityController = ExclusivityController<ExlcusivityCategory>(operationQueue: operationQueue) - - private let rest = MullvadRest() - private let queue: SKPaymentQueue - - private var observerList = ObserverList<AnyAppStorePaymentObserver>() - private let lock = NSRecursiveLock() - - private weak var classDelegate: AppStorePaymentManagerDelegate? - weak var delegate: AppStorePaymentManagerDelegate? { - get { - lock.withCriticalBlock { - return classDelegate - } - } - set { - lock.withCriticalBlock { - classDelegate = newValue - } - } - } - - /// A private hash map that maps each payment to account token - private var paymentToAccountToken = [SKPayment: String]() - - /// Returns true if the device is able to make payments - class var canMakePayments: Bool { - return SKPaymentQueue.canMakePayments() - } - - init(queue: SKPaymentQueue) { - self.queue = queue - } - - func startPaymentQueueMonitoring() { - self.logger.debug("Start payment queue monitoring.") - queue.add(self) - } - - // MARK: - SKPaymentTransactionObserver - - func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) { - for transaction in transactions { - self.handleTransaction(transaction) - } - } - - // MARK: - Payment observation - - func addPaymentObserver<T: AppStorePaymentObserver>(_ observer: T) { - self.observerList.append(AnyAppStorePaymentObserver(observer)) - } - - func removePaymentObserver<T: AppStorePaymentObserver>(_ observer: T) { - observerList.remove(AnyAppStorePaymentObserver(observer)) - } - - // MARK: - Account token and payment mapping - - private func associateAccountToken(_ token: String, and payment: SKPayment) { - lock.withCriticalBlock { - paymentToAccountToken[payment] = token - } - } - - private func deassociateAccountToken(_ payment: SKPayment) -> String? { - return lock.withCriticalBlock { - if let accountToken = paymentToAccountToken[payment] { - paymentToAccountToken.removeValue(forKey: payment) - return accountToken - } else { - return self.classDelegate? - .appStorePaymentManager(self, didRequestAccountTokenFor: payment) - } - } - } - - // MARK: - Products and payments - - func requestProducts(with productIdentifiers: Set<AppStoreSubscription>, - completionHandler: @escaping (Result<SKProductsResponse, Swift.Error>) -> Void) - { - let productIdentifiers = productIdentifiers.productIdentifiersSet - - let retryStrategy = RetryStrategy( - maxRetries: 10, - waitStrategy: .constant(2), - waitTimerType: .deadline - ) - - let operation = RetryOperation(strategy: retryStrategy) { () -> ProductsRequestOperation in - let request = SKProductsRequest(productIdentifiers: productIdentifiers) - return ProductsRequestOperation(request: request) - } - - operation.addDidFinishBlockObserver { (operation, result) in - completionHandler(result) - } - - operationQueue.addOperation(operation) - } - - func addPayment(_ payment: SKPayment, for accountToken: String) { - associateAccountToken(accountToken, and: payment) - queue.add(payment) - } - - func restorePurchases( - for accountToken: String, - completionHandler: @escaping (Result<CreateApplePaymentResponse, AppStorePaymentManager.Error>) -> Void) { - return sendAppStoreReceipt( - accountToken: accountToken, - forceRefresh: true, - completionHandler: completionHandler - ) - } - - // MARK: - Private methods - - private func sendAppStoreReceipt(accountToken: String, forceRefresh: Bool, completionHandler: @escaping (Result<CreateApplePaymentResponse, Error>) -> Void) - { - AppStoreReceipt.fetch(forceRefresh: forceRefresh) { (result) in - switch result { - case .success(let receiptData): - let payload = TokenPayload<CreateApplePaymentRequest>(token: accountToken, payload: CreateApplePaymentRequest(receiptString: receiptData)) - - let createApplePaymentOperation = self.rest.createApplePayment() - .operation(payload: payload) - - createApplePaymentOperation.addDidFinishBlockObserver { (operation, result) in - switch result { - case .success(let response): - self.logger.info("AppStore receipt was processed. Time added: \(response.timeAdded), New expiry: \(response.newExpiry)") - - completionHandler(.success(response)) - - case .failure(let error): - self.logger.error(chainedError: error, message: "Failed to upload the AppStore receipt") - - completionHandler(.failure(.sendReceipt(error))) - } - } - - self.exclusivityController.addOperation(createApplePaymentOperation, categories: [.sendReceipt]) - - case .failure(let error): - self.logger.error(chainedError: error, message: "Failed to fetch the AppStore receipt") - - completionHandler(.failure(.readReceipt(error))) - } - } - } - - private func handleTransaction(_ transaction: SKPaymentTransaction) { - switch transaction.transactionState { - case .deferred: - logger.info("Deferred \(transaction.payment.productIdentifier)") - - case .failed: - logger.error("Failed to purchase \(transaction.payment.productIdentifier): \(transaction.error?.localizedDescription ?? "No error")") - - didFailPurchase(transaction: transaction) - - case .purchased: - logger.info("Purchased \(transaction.payment.productIdentifier)") - - didFinishOrRestorePurchase(transaction: transaction) - - case .purchasing: - logger.info("Purchasing \(transaction.payment.productIdentifier)") - - case .restored: - logger.info("Restored \(transaction.payment.productIdentifier)") - - didFinishOrRestorePurchase(transaction: transaction) - - @unknown default: - logger.warning("Unknown transactionState = \(transaction.transactionState.rawValue)") - } - } - - private func didFailPurchase(transaction: SKPaymentTransaction) { - queue.finishTransaction(transaction) - - guard let accountToken = deassociateAccountToken(transaction.payment) else { - observerList.forEach { (observer) in - observer.appStorePaymentManager( - self, - transaction: transaction, - accountToken: nil, - didFailWithError: .noAccountSet) - } - return - } - - observerList.forEach { (observer) in - observer.appStorePaymentManager( - self, - transaction: transaction, - accountToken: accountToken, - didFailWithError: .storePayment(transaction.error!)) - } - - } - - private func didFinishOrRestorePurchase(transaction: SKPaymentTransaction) { - guard let accountToken = deassociateAccountToken(transaction.payment) else { - observerList.forEach { (observer) in - observer.appStorePaymentManager( - self, - transaction: transaction, - accountToken: nil, - didFailWithError: .noAccountSet) - } - return - } - - sendAppStoreReceipt(accountToken: accountToken, forceRefresh: false) { (result) in - DispatchQueue.main.async { - switch result { - case .success(let response): - self.queue.finishTransaction(transaction) - - self.observerList.forEach { (observer) in - observer.appStorePaymentManager( - self, - transaction: transaction, - accountToken: accountToken, - didFinishWithResponse: response) - } - - case .failure(let error): - self.observerList.forEach { (observer) in - observer.appStorePaymentManager( - self, - transaction: transaction, - accountToken: accountToken, - didFailWithError: error) - } - } - } - } - } - -} - -private class ProductsRequestOperation: AsyncOperation, OutputOperation, SKProductsRequestDelegate { - typealias Output = Result<SKProductsResponse, Error> - - private let request: SKProductsRequest - - init(request: SKProductsRequest) { - self.request = request - super.init() - - request.delegate = self - } - - override func main() { - request.start() - } - - override func operationDidCancel() { - request.cancel() - } - - // - MARK: SKProductsRequestDelegate - - func requestDidFinish(_ request: SKRequest) { - // no-op - } - - func request(_ request: SKRequest, didFailWithError error: Error) { - finish(with: .failure(error)) - } - - func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) { - finish(with: .success(response)) - } -} diff --git a/ios/MullvadVPN/AppStorePaymentManager/AnyAppStorePaymentObserver.swift b/ios/MullvadVPN/AppStorePaymentManager/AnyAppStorePaymentObserver.swift new file mode 100644 index 0000000000..0115d57949 --- /dev/null +++ b/ios/MullvadVPN/AppStorePaymentManager/AnyAppStorePaymentObserver.swift @@ -0,0 +1,47 @@ +// +// AnyAppStorePaymentObserver.swift +// AnyAppStorePaymentObserver +// +// Created by pronebird on 03/09/2021. +// Copyright © 2021 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import StoreKit + +/// A type-erasing weak container for `AppStorePaymentObserver` +class AnyAppStorePaymentObserver: AppStorePaymentObserver, WeakObserverBox, Equatable { + private(set) weak var inner: AppStorePaymentObserver? + + init<T: AppStorePaymentObserver>(_ inner: T) { + self.inner = inner + } + + func appStorePaymentManager(_ manager: AppStorePaymentManager, + transaction: SKPaymentTransaction, + accountToken: String?, + didFailWithError error: AppStorePaymentManager.Error) + { + self.inner?.appStorePaymentManager( + manager, + transaction: transaction, + accountToken: accountToken, + didFailWithError: error) + } + + func appStorePaymentManager(_ manager: AppStorePaymentManager, + transaction: SKPaymentTransaction, + accountToken: String, + didFinishWithResponse response: REST.CreateApplePaymentResponse) + { + self.inner?.appStorePaymentManager( + manager, + transaction: transaction, + accountToken: accountToken, + didFinishWithResponse: response) + } + + static func == (lhs: AnyAppStorePaymentObserver, rhs: AnyAppStorePaymentObserver) -> Bool { + return lhs.inner === rhs.inner + } +} diff --git a/ios/MullvadVPN/AppStorePaymentManager/AppStorePaymentManager.swift b/ios/MullvadVPN/AppStorePaymentManager/AppStorePaymentManager.swift new file mode 100644 index 0000000000..d096286938 --- /dev/null +++ b/ios/MullvadVPN/AppStorePaymentManager/AppStorePaymentManager.swift @@ -0,0 +1,270 @@ +// +// AppStorePaymentManager.swift +// MullvadVPN +// +// Created by pronebird on 10/03/2020. +// Copyright © 2020 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import StoreKit +import Logging + +class AppStorePaymentManager: NSObject, SKPaymentTransactionObserver { + + private enum OperationCategory { + static let sendAppStoreReceipt = "AppStorePaymentManager.sendAppStoreReceipt" + static let productsRequest = "AppStorePaymentManager.productsRequest" + } + + /// A shared instance of `AppStorePaymentManager` + static let shared = AppStorePaymentManager(queue: SKPaymentQueue.default()) + + private let logger = Logger(label: "AppStorePaymentManager") + + private let operationQueue: OperationQueue = { + let queue = OperationQueue() + queue.name = "AppStorePaymentManagerQueue" + return queue + }() + + private let paymentQueue: SKPaymentQueue + private var observerList = ObserverList<AnyAppStorePaymentObserver>() + + private weak var classDelegate: AppStorePaymentManagerDelegate? + weak var delegate: AppStorePaymentManagerDelegate? { + get { + if Thread.isMainThread { + return classDelegate + } else { + return DispatchQueue.main.sync { + return classDelegate + } + } + } + set { + if Thread.isMainThread { + classDelegate = newValue + } else { + DispatchQueue.main.async { + self.classDelegate = newValue + } + } + } + } + + /// A private hash map that maps each payment to account token + private var paymentToAccountToken = [SKPayment: String]() + + /// Returns true if the device is able to make payments + class var canMakePayments: Bool { + return SKPaymentQueue.canMakePayments() + } + + init(queue: SKPaymentQueue) { + self.paymentQueue = queue + } + + func startPaymentQueueMonitoring() { + self.logger.debug("Start payment queue monitoring") + paymentQueue.add(self) + } + + // MARK: - SKPaymentTransactionObserver + + func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) { + // Ensure that all calls happen on main queue + if Thread.isMainThread { + handleTransactions(transactions) + } else { + DispatchQueue.main.async { + self.handleTransactions(transactions) + } + } + } + + // MARK: - Payment observation + + func addPaymentObserver<T: AppStorePaymentObserver>(_ observer: T) { + observerList.append(AnyAppStorePaymentObserver(observer)) + } + + func removePaymentObserver<T: AppStorePaymentObserver>(_ observer: T) { + observerList.remove(AnyAppStorePaymentObserver(observer)) + } + + // MARK: - Products and payments + + func requestProducts(with productIdentifiers: Set<AppStoreSubscription>) -> Result<SKProductsResponse, Swift.Error>.Promise { + return Promise { resolver in + let productIdentifiers = productIdentifiers.productIdentifiersSet + let operation = ProductsRequestOperation(productIdentifiers: productIdentifiers) { result in + resolver.resolve(value: result) + } + + resolver.setCancelHandler { + operation.cancel() + } + + ExclusivityController.shared.addOperation(operation, categories: [OperationCategory.productsRequest]) + self.operationQueue.addOperation(operation) + } + } + + func addPayment(_ payment: SKPayment, for accountToken: String) { + if Thread.isMainThread { + _addPayment(payment, for: accountToken) + } else { + DispatchQueue.main.async { + self._addPayment(payment, for: accountToken) + } + } + } + + func restorePurchases(for accountToken: String) -> Result<REST.CreateApplePaymentResponse, AppStorePaymentManager.Error>.Promise { + return sendAppStoreReceipt(accountToken: accountToken, forceRefresh: true) + .requestBackgroundTime(taskName: "AppStorePaymentManager.restorePurchases") + } + + + // MARK: - Private methods + + private func associateAccountToken(_ token: String, and payment: SKPayment) { + assert(Thread.isMainThread) + + paymentToAccountToken[payment] = token + } + + private func deassociateAccountToken(_ payment: SKPayment) -> String? { + assert(Thread.isMainThread) + + if let accountToken = paymentToAccountToken[payment] { + paymentToAccountToken.removeValue(forKey: payment) + return accountToken + } else { + return classDelegate?.appStorePaymentManager(self, didRequestAccountTokenFor: payment) + } + } + + private func _addPayment(_ payment: SKPayment, for accountToken: String) { + assert(Thread.isMainThread) + + associateAccountToken(accountToken, and: payment) + paymentQueue.add(payment) + } + + private func sendAppStoreReceipt(accountToken: String, forceRefresh: Bool) -> Result<REST.CreateApplePaymentResponse, Error>.Promise { + return AppStoreReceipt.fetch(forceRefresh: forceRefresh) + .mapError { error in + self.logger.error(chainedError: error, message: "Failed to fetch the AppStore receipt") + + return .readReceipt(error) + } + .mapThen { receiptData in + return REST.Client.shared.createApplePayment(token: accountToken, receiptString: receiptData) + .mapError { error in + self.logger.error(chainedError: error, message: "Failed to upload the AppStore receipt") + + return .sendReceipt(error) + } + .onSuccess{ response in + self.logger.info("AppStore receipt was processed. Time added: \(response.timeAdded), New expiry: \(response.newExpiry.logFormatDate())") + } + } + .run(on: operationQueue, categories: [OperationCategory.sendAppStoreReceipt]) + } + + private func handleTransactions(_ transactions: [SKPaymentTransaction]) { + transactions.forEach { transaction in + handleTransaction(transaction) + } + } + + private func handleTransaction(_ transaction: SKPaymentTransaction) { + switch transaction.transactionState { + case .deferred: + logger.info("Deferred \(transaction.payment.productIdentifier)") + + case .failed: + logger.error("Failed to purchase \(transaction.payment.productIdentifier): \(transaction.error?.localizedDescription ?? "No error")") + + didFailPurchase(transaction: transaction) + + case .purchased: + logger.info("Purchased \(transaction.payment.productIdentifier)") + + didFinishOrRestorePurchase(transaction: transaction) + + case .purchasing: + logger.info("Purchasing \(transaction.payment.productIdentifier)") + + case .restored: + logger.info("Restored \(transaction.payment.productIdentifier)") + + didFinishOrRestorePurchase(transaction: transaction) + + @unknown default: + logger.warning("Unknown transactionState = \(transaction.transactionState.rawValue)") + } + } + + private func didFailPurchase(transaction: SKPaymentTransaction) { + paymentQueue.finishTransaction(transaction) + + if let accountToken = deassociateAccountToken(transaction.payment) { + observerList.forEach { (observer) in + observer.appStorePaymentManager( + self, + transaction: transaction, + accountToken: accountToken, + didFailWithError: .storePayment(transaction.error!)) + } + } else { + observerList.forEach { (observer) in + observer.appStorePaymentManager( + self, + transaction: transaction, + accountToken: nil, + didFailWithError: .noAccountSet) + } + } + } + + private func didFinishOrRestorePurchase(transaction: SKPaymentTransaction) { + if let accountToken = deassociateAccountToken(transaction.payment) { + sendAppStoreReceipt(accountToken: accountToken, forceRefresh: false) + .receive(on: .main) + .onSuccess { response in + self.paymentQueue.finishTransaction(transaction) + + self.observerList.forEach { (observer) in + observer.appStorePaymentManager( + self, + transaction: transaction, + accountToken: accountToken, + didFinishWithResponse: response) + } + } + .onFailure { error in + self.observerList.forEach { (observer) in + observer.appStorePaymentManager( + self, + transaction: transaction, + accountToken: accountToken, + didFailWithError: error) + } + } + .requestBackgroundTime(taskName: "AppStorePaymentManager.didFinishOrRestorePurchase") + .observe { _ in } + } else { + observerList.forEach { (observer) in + observer.appStorePaymentManager( + self, + transaction: transaction, + accountToken: nil, + didFailWithError: .noAccountSet) + } + } + } + +} diff --git a/ios/MullvadVPN/AppStorePaymentManager/AppStorePaymentManagerDelegate.swift b/ios/MullvadVPN/AppStorePaymentManager/AppStorePaymentManagerDelegate.swift new file mode 100644 index 0000000000..a56fa2d1e2 --- /dev/null +++ b/ios/MullvadVPN/AppStorePaymentManager/AppStorePaymentManagerDelegate.swift @@ -0,0 +1,18 @@ +// +// AppStorePaymentManagerDelegate.swift +// AppStorePaymentManagerDelegate +// +// Created by pronebird on 03/09/2021. +// Copyright © 2021 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import StoreKit + +protocol AppStorePaymentManagerDelegate: AnyObject { + + /// Return the account token associated with the payment. + /// Usually called for unfinished transactions coming back after the app was restarted. + func appStorePaymentManager(_ manager: AppStorePaymentManager, + didRequestAccountTokenFor payment: SKPayment) -> String? +} diff --git a/ios/MullvadVPN/AppStorePaymentManager/AppStorePaymentManagerError.swift b/ios/MullvadVPN/AppStorePaymentManager/AppStorePaymentManagerError.swift new file mode 100644 index 0000000000..5d395c4e48 --- /dev/null +++ b/ios/MullvadVPN/AppStorePaymentManager/AppStorePaymentManagerError.swift @@ -0,0 +1,39 @@ +// +// AppStorePaymentManagerError.swift +// AppStorePaymentManagerError +// +// Created by pronebird on 08/09/2021. +// Copyright © 2021 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +extension AppStorePaymentManager { + /// An error type emitted by `AppStorePaymentManager`. + enum Error: ChainedError { + /// Failure to find the account token associated with the transaction. + case noAccountSet + + /// Failure to handle payment transaction. Contains error returned by StoreKit. + case storePayment(Swift.Error) + + /// Failure to read the AppStore receipt. + case readReceipt(AppStoreReceipt.Error) + + /// Failure to send the AppStore receipt to backend. + case sendReceipt(REST.Error) + + var errorDescription: String? { + switch self { + case .noAccountSet: + return "Account is not set" + case .storePayment: + return "Store payment error" + case .readReceipt: + return "Read recept error" + case .sendReceipt: + return "Send receipt error" + } + } + } +} diff --git a/ios/MullvadVPN/AppStorePaymentManager/AppStorePaymentObserver.swift b/ios/MullvadVPN/AppStorePaymentManager/AppStorePaymentObserver.swift new file mode 100644 index 0000000000..9aa4cc9481 --- /dev/null +++ b/ios/MullvadVPN/AppStorePaymentManager/AppStorePaymentObserver.swift @@ -0,0 +1,24 @@ +// +// AppStorePaymentObserver.swift +// AppStorePaymentObserver +// +// Created by pronebird on 03/09/2021. +// Copyright © 2021 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import StoreKit + +protocol AppStorePaymentObserver: AnyObject { + func appStorePaymentManager( + _ manager: AppStorePaymentManager, + transaction: SKPaymentTransaction, + accountToken: String?, + didFailWithError error: AppStorePaymentManager.Error) + + func appStorePaymentManager( + _ manager: AppStorePaymentManager, + transaction: SKPaymentTransaction, + accountToken: String, + didFinishWithResponse response: REST.CreateApplePaymentResponse) +} diff --git a/ios/MullvadVPN/AppStorePaymentManager/AppStoreSubscription.swift b/ios/MullvadVPN/AppStorePaymentManager/AppStoreSubscription.swift new file mode 100644 index 0000000000..a46e2310bf --- /dev/null +++ b/ios/MullvadVPN/AppStorePaymentManager/AppStoreSubscription.swift @@ -0,0 +1,38 @@ +// +// AppStoreSubscription.swift +// AppStoreSubscription +// +// Created by pronebird on 03/09/2021. +// Copyright © 2021 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import StoreKit + +enum AppStoreSubscription: String { + /// Thirty days non-renewable subscription + case thirtyDays = "net.mullvad.MullvadVPN.subscription.30days" + + var localizedTitle: String { + switch self { + case .thirtyDays: + return NSLocalizedString( + "APPSTORE_SUBSCRIPTION_TITLE_ADD_30_DAYS", + tableName: "AppStoreSubscriptions", + comment: "Title for non-renewable subscription that credits 30 days to user account." + ) + } + } +} + +extension SKProduct { + var customLocalizedTitle: String? { + return AppStoreSubscription(rawValue: productIdentifier)?.localizedTitle + } +} + +extension Set where Element == AppStoreSubscription { + var productIdentifiersSet: Set<String> { + Set<String>(self.map { $0.rawValue }) + } +} diff --git a/ios/MullvadVPN/Logging/LogFormatting.swift b/ios/MullvadVPN/Logging/LogFormatting.swift new file mode 100644 index 0000000000..2ccbdf4fc6 --- /dev/null +++ b/ios/MullvadVPN/Logging/LogFormatting.swift @@ -0,0 +1,18 @@ +// +// LogFormatting.swift +// LogFormatting +// +// Created by pronebird on 09/09/2021. +// Copyright © 2021 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +extension Date { + func logFormatDate() -> String { + let formatter = DateFormatter() + formatter.dateFormat = "dd/MM/yyyy @ HH:mm" + + return formatter.string(from: self) + } +} diff --git a/ios/MullvadVPN/Operations/ProductsRequestOperation.swift b/ios/MullvadVPN/Operations/ProductsRequestOperation.swift new file mode 100644 index 0000000000..e1f8c99136 --- /dev/null +++ b/ios/MullvadVPN/Operations/ProductsRequestOperation.swift @@ -0,0 +1,95 @@ +// +// ProductsRequestOperation.swift +// ProductsRequestOperation +// +// Created by pronebird on 02/09/2021. +// Copyright © 2021 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import StoreKit + +class ProductsRequestOperation: AsyncOperation, SKProductsRequestDelegate { + private let productIdentifiers: Set<String> + private let completionHandler: (Result<SKProductsResponse, Error>) -> Void + + private let maxRetryCount = 10 + private let retryDelay: DispatchTimeInterval = .seconds(2) + + private var retryCount = 0 + private var retryTimer: DispatchSourceTimer? + private var request: SKProductsRequest? + + init(productIdentifiers: Set<String>, completionHandler: @escaping (Result<SKProductsResponse, Error>) -> Void) { + self.productIdentifiers = productIdentifiers + self.completionHandler = completionHandler + + super.init() + } + + override func main() { + DispatchQueue.main.async { + self.startRequest() + } + } + + override func cancel() { + super.cancel() + + DispatchQueue.main.async { + self.request?.cancel() + self.retryTimer?.cancel() + } + } + + // - MARK: SKProductsRequestDelegate + + func requestDidFinish(_ request: SKRequest) { + // no-op + } + + func request(_ request: SKRequest, didFailWithError error: Error) { + DispatchQueue.main.async { + if self.retryCount < self.maxRetryCount, !self.isCancelled { + self.retryCount += 1 + self.retry(error: error) + } else { + self.finish(with: .failure(error)) + } + } + } + + func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) { + DispatchQueue.main.async { + self.finish(with: .success(response)) + } + } + + // MARK: - Private + + private func startRequest() { + request = SKProductsRequest(productIdentifiers: productIdentifiers) + request?.delegate = self + request?.start() + } + + private func retry(error: Error) { + retryTimer = DispatchSource.makeTimerSource(flags: [], queue: .main) + + retryTimer?.setEventHandler { [weak self] in + self?.startRequest() + } + + retryTimer?.setCancelHandler { [weak self] in + self?.finish(with: .failure(error)) + } + + retryTimer?.schedule(wallDeadline: .now() + self.retryDelay) + retryTimer?.activate() + } + + private func finish(with result: Result<SKProductsResponse, Error>) { + completionHandler(result) + finish() + } +} |
