summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAndrej Mihajlov <and@mullvad.net>2021-09-15 14:32:24 +0200
committerAndrej Mihajlov <and@mullvad.net>2021-09-16 12:57:29 +0200
commitf9be34013ae82bf4d4a2c605485f66ab14c55b6e (patch)
tree4cf489fa45b4a84266916d6ba08a284bc87568ce
parent3ee0fdffa3750f4971146f85e136412f20a9326c (diff)
downloadmullvadvpn-f9be34013ae82bf4d4a2c605485f66ab14c55b6e.tar.xz
mullvadvpn-f9be34013ae82bf4d4a2c605485f66ab14c55b6e.zip
AppStorePaymentManager: refactor
-rw-r--r--ios/MullvadVPN.xcodeproj/project.pbxproj40
-rw-r--r--ios/MullvadVPN/AppStorePaymentManager.swift411
-rw-r--r--ios/MullvadVPN/AppStorePaymentManager/AnyAppStorePaymentObserver.swift47
-rw-r--r--ios/MullvadVPN/AppStorePaymentManager/AppStorePaymentManager.swift270
-rw-r--r--ios/MullvadVPN/AppStorePaymentManager/AppStorePaymentManagerDelegate.swift18
-rw-r--r--ios/MullvadVPN/AppStorePaymentManager/AppStorePaymentManagerError.swift39
-rw-r--r--ios/MullvadVPN/AppStorePaymentManager/AppStorePaymentObserver.swift24
-rw-r--r--ios/MullvadVPN/AppStorePaymentManager/AppStoreSubscription.swift38
-rw-r--r--ios/MullvadVPN/Logging/LogFormatting.swift18
-rw-r--r--ios/MullvadVPN/Operations/ProductsRequestOperation.swift95
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()
+ }
+}