From 5aa67bcc1859ea773e433acda089edf41caed1b4 Mon Sep 17 00:00:00 2001 From: Andrej Mihajlov Date: Thu, 26 Oct 2023 17:46:36 +0200 Subject: [PATCH] Introduce transaction log to prevent handling already processed transactions --- ios/MullvadVPN.xcodeproj/project.pbxproj | 6 +- ios/MullvadVPN/AppDelegate.swift | 7 +- .../StorePaymentEvent.swift | 22 ++ .../StorePaymentManager.swift | 280 +++++++++++++----- .../StorePaymentManagerDelegate.swift | 7 +- .../StoreTransactionLog.swift | 145 +++++++++ 6 files changed, 377 insertions(+), 90 deletions(-) create mode 100644 ios/MullvadVPN/StorePaymentManager/StoreTransactionLog.swift diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index 8a12966bce28..5d06c044b626 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -381,6 +381,7 @@ 58F2E14C276A61C000A79513 /* RotateKeyOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58F2E14B276A61C000A79513 /* RotateKeyOperation.swift */; }; 58F3C0A4249CB069003E76BE /* HeaderBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58F3C0A3249CB069003E76BE /* HeaderBarView.swift */; }; 58F3F36A2AA08E3C00D3B0A4 /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58F3F3692AA08E3C00D3B0A4 /* PacketTunnelProvider.swift */; }; + 58F70FE52AEA707800E6890E /* StoreTransactionLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58F70FE42AEA707800E6890E /* StoreTransactionLog.swift */; }; 58F7753D2AB8473200425B47 /* BlockedStateErrorMapperStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58F7753C2AB8473200425B47 /* BlockedStateErrorMapperStub.swift */; }; 58F8AC0E25D3F8CE002BE0ED /* ProblemReportReviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58F8AC0D25D3F8CE002BE0ED /* ProblemReportReviewViewController.swift */; }; 58FB865526E8BF3100F188BC /* StorePaymentManagerError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FB865426E8BF3100F188BC /* StorePaymentManagerError.swift */; }; @@ -1479,6 +1480,7 @@ 58F3C0A524A50155003E76BE /* relays.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = relays.json; sourceTree = ""; }; 58F3F3652AA086A400D3B0A4 /* AutoCancellingTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCancellingTask.swift; sourceTree = ""; }; 58F3F3692AA08E3C00D3B0A4 /* PacketTunnelProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketTunnelProvider.swift; sourceTree = ""; }; + 58F70FE42AEA707800E6890E /* StoreTransactionLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreTransactionLog.swift; sourceTree = ""; }; 58F7753C2AB8473200425B47 /* BlockedStateErrorMapperStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockedStateErrorMapperStub.swift; sourceTree = ""; }; 58F7D26427EB50A300E4D821 /* ResultOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResultOperation.swift; sourceTree = ""; }; 58F8AC0D25D3F8CE002BE0ED /* ProblemReportReviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProblemReportReviewViewController.swift; sourceTree = ""; }; @@ -2279,14 +2281,15 @@ 5846226F26E229CD0035F7C2 /* StorePaymentManager */ = { isa = PBXGroup; children = ( - 5878A27629093A4F0096FC88 /* StorePaymentBlockObserver.swift */, 585E820227F3285E00939F0E /* SendStoreReceiptOperation.swift */, + 5878A27629093A4F0096FC88 /* StorePaymentBlockObserver.swift */, 5878A27429093A310096FC88 /* StorePaymentEvent.swift */, 58DF28A42417CB4B00E836B0 /* StorePaymentManager.swift */, 5846227626E22A7C0035F7C2 /* StorePaymentManagerDelegate.swift */, 58FB865426E8BF3100F188BC /* StorePaymentManagerError.swift */, 5846227226E22A160035F7C2 /* StorePaymentObserver.swift */, 5846227026E229F20035F7C2 /* StoreSubscription.swift */, + 58F70FE42AEA707800E6890E /* StoreTransactionLog.swift */, ); path = StorePaymentManager; sourceTree = ""; @@ -4342,6 +4345,7 @@ 5878A279290954790096FC88 /* TunnelViewControllerInteractor.swift in Sources */, 7A818F1F29F0305800C7F0F4 /* RootConfiguration.swift in Sources */, 7A9CCCBF2A96302800DD6A34 /* SettingsCoordinator.swift in Sources */, + 58F70FE52AEA707800E6890E /* StoreTransactionLog.swift in Sources */, 582AE3102440A6CA00E6733A /* InputTextFormatter.swift in Sources */, 7A9CCCBA2A96302800DD6A34 /* CreateAccountVoucherCoordinator.swift in Sources */, 5820EDAB288FF0D2006BF4E4 /* DeviceRowView.swift in Sources */, diff --git a/ios/MullvadVPN/AppDelegate.swift b/ios/MullvadVPN/AppDelegate.swift index 9e9e1cc9c0bf..bd8265b50c18 100644 --- a/ios/MullvadVPN/AppDelegate.swift +++ b/ios/MullvadVPN/AppDelegate.swift @@ -87,10 +87,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD tunnelManager.addObserver(relayConstraintsObserver) storePaymentManager = StorePaymentManager( - application: application, + backgroundTaskProvider: application, queue: .default(), apiProxy: apiProxy, - accountsProxy: accountsProxy + accountsProxy: accountsProxy, + transactionLog: .default ) let urlSessionTransport = URLSessionTransport(urlSession: REST.makeURLSession()) @@ -448,7 +449,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD self.logger.debug("Finished initialization.") NotificationManager.shared.updateNotifications() - self.storePaymentManager.startPaymentQueueMonitoring() + self.storePaymentManager.start() finish(nil) } diff --git a/ios/MullvadVPN/StorePaymentManager/StorePaymentEvent.swift b/ios/MullvadVPN/StorePaymentManager/StorePaymentEvent.swift index ed03a591217d..7d017e9dd776 100644 --- a/ios/MullvadVPN/StorePaymentManager/StorePaymentEvent.swift +++ b/ios/MullvadVPN/StorePaymentManager/StorePaymentEvent.swift @@ -10,10 +10,15 @@ import Foundation import MullvadREST import StoreKit +/// The payment event received by observers implementing ``StorePaymentObserver``. enum StorePaymentEvent { + /// The payment is successfully completed. case finished(StorePaymentCompletion) + + /// Failure to complete the payment. case failure(StorePaymentFailure) + /// An instance of `SKPayment` held in the associated value. var payment: SKPayment { switch self { case let .finished(completion): @@ -24,15 +29,32 @@ enum StorePaymentEvent { } } +/// Successful payment metadata. struct StorePaymentCompletion { + /// Transaction object. let transaction: SKPaymentTransaction + + /// The account number credited. let accountNumber: String + + /// The server response received after uploading the AppStore receipt. let serverResponse: REST.CreateApplePaymentResponse } +/// Failed payment metadata. struct StorePaymentFailure { + /// Transaction object, if available. + /// May not be available due to account validation failure. let transaction: SKPaymentTransaction? + + /// The payment object associated with payment request. let payment: SKPayment + + /// The account number to credit. + /// May not be available if the payment manager couldn't establish the association between the payment and account number. + /// Typically in such case, the error would be set to ``StorePaymentManagerError/noAccountSet``. let accountNumber: String? + + /// The payment manager error. let error: StorePaymentManagerError } diff --git a/ios/MullvadVPN/StorePaymentManager/StorePaymentManager.swift b/ios/MullvadVPN/StorePaymentManager/StorePaymentManager.swift index a5cdb6237e7e..3a57873e4536 100644 --- a/ios/MullvadVPN/StorePaymentManager/StorePaymentManager.swift +++ b/ios/MullvadVPN/StorePaymentManager/StorePaymentManager.swift @@ -13,6 +13,9 @@ import Operations import StoreKit import UIKit +/// Manager responsible for handling AppStore payments and passing StoreKit receipts to the backend. +/// +/// - Warning: only interact with this object on the main queue. final class StorePaymentManager: NSObject, SKPaymentTransactionObserver { private enum OperationCategory { static let sendStoreReceipt = "StorePaymentManager.sendStoreReceipt" @@ -27,35 +30,17 @@ final class StorePaymentManager: NSObject, SKPaymentTransactionObserver { return queue }() - private let application: UIApplication + private let backgroundTaskProvider: BackgroundTaskProvider private let paymentQueue: SKPaymentQueue private let apiProxy: APIQuerying private let accountsProxy: RESTAccountHandling private var observerList = ObserverList() + private let transactionLog: StoreTransactionLog - private weak var classDelegate: StorePaymentManagerDelegate? - weak var delegate: StorePaymentManagerDelegate? { - get { - if Thread.isMainThread { - return classDelegate - } else { - return DispatchQueue.main.sync { - classDelegate - } - } - } - set { - if Thread.isMainThread { - classDelegate = newValue - } else { - DispatchQueue.main.async { - self.classDelegate = newValue - } - } - } - } + /// Payment manager's delegate. + weak var delegate: StorePaymentManagerDelegate? - /// A private hash map that maps each payment to account token. + /// A dictionary that maps each payment to account number. private var paymentToAccountToken = [SKPayment: String]() /// Returns true if the device is able to make payments. @@ -63,51 +48,70 @@ final class StorePaymentManager: NSObject, SKPaymentTransactionObserver { SKPaymentQueue.canMakePayments() } + /// Designated initializer + /// + /// - Parameters: + /// - backgroundTaskProvider: the background task provider. + /// - queue: the payment queue. Typically `SKPaymentQueue.default()`. + /// - apiProxy: the object implement `APIQuerying` + /// - accountsProxy: the object implementing `RESTAccountHandling`. + /// - transactionLog: an instance of transaction log. Typically ``StoreTransactionLog/default``. init( - application: UIApplication, + backgroundTaskProvider: BackgroundTaskProvider, queue: SKPaymentQueue, apiProxy: APIQuerying, - accountsProxy: RESTAccountHandling + accountsProxy: RESTAccountHandling, + transactionLog: StoreTransactionLog ) { - self.application = application + self.backgroundTaskProvider = backgroundTaskProvider paymentQueue = queue self.apiProxy = apiProxy self.accountsProxy = accountsProxy + self.transactionLog = transactionLog } - func startPaymentQueueMonitoring() { + /// Loads transaction log from disk and starts monitoring payment queue. + func start() { + // Load transaction log from file before starting the payment queue. + logger.debug("Load transaction log.") + transactionLog.read() + 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) - } + func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) { + // Ensure that all calls happen on main queue because StoreKit does not guarantee on which queue the delegate + // will be invoked. + DispatchQueue.main.async { + self.handleTransactions(transactions) } } // MARK: - Payment observation + /// Add payment observer + /// - Parameter observer: an observer object. func addPaymentObserver(_ observer: StorePaymentObserver) { observerList.append(observer) } + /// Remove payment observer + /// - Parameter observer: an observer object. func removePaymentObserver(_ observer: StorePaymentObserver) { observerList.remove(observer) } // MARK: - Products and payments + /// Fetch products from AppStore using product identifiers. + /// + /// - Parameters: + /// - productIdentifiers: a set of product identifiers. + /// - completionHandler: completion handler. Invoked on main queue. + /// - Returns: the request cancellation token func requestProducts( with productIdentifiers: Set, completionHandler: @escaping (Result) -> Void @@ -124,10 +128,21 @@ final class StorePaymentManager: NSObject, SKPaymentTransactionObserver { return operation } + /// Add payment and associate it with the account number. + /// + /// Validates the user account with backend before adding the payment to the queue. + /// + /// - Parameters: + /// - payment: an intance of `SKPayment`. + /// - accountNumber: the account number to credit. func addPayment(_ payment: SKPayment, for accountNumber: String) { + logger.debug("Validating account before the purchase.") + // Validate account token before adding new payment to the queue. validateAccount(accountNumber: accountNumber) { error in if let error { + self.logger.error("Failed to validate the account. Payment is ignored.") + let event = StorePaymentEvent.failure( StorePaymentFailure( transaction: nil, @@ -141,17 +156,27 @@ final class StorePaymentManager: NSObject, SKPaymentTransactionObserver { observer.storePaymentManager(self, didReceiveEvent: event) } } else { - self.associateAccountToken(accountNumber, and: payment) + self.logger.debug("Add payment to the queue.") + + self.associateAccountNumber(accountNumber, and: payment) self.paymentQueue.add(payment) } } } + /// Restore purchases by sending the AppStore receipt to backend. + /// + /// - Parameters: + /// - accountNumber: the account number to credit. + /// - completionHandler: completion handler invoked on the main queue. + /// - Returns: the request cancellation token. func restorePurchases( for accountNumber: String, completionHandler: @escaping (Result) -> Void ) -> Cancellable { - sendStoreReceipt( + logger.debug("Restore purchases.") + + return sendStoreReceipt( accountNumber: accountNumber, forceRefresh: true, completionHandler: completionHandler @@ -160,23 +185,41 @@ final class StorePaymentManager: NSObject, SKPaymentTransactionObserver { // MARK: - Private methods - private func associateAccountToken(_ token: String, and payment: SKPayment) { - assert(Thread.isMainThread) + /// Associate account number with the payment object. + /// + /// - Parameters: + /// - accountNumber: the account number that should be credited with the payment. + /// - payment: the payment object. + private func associateAccountNumber(_ accountNumber: String, and payment: SKPayment) { + dispatchPrecondition(condition: .onQueue(.main)) - paymentToAccountToken[payment] = token + paymentToAccountToken[payment] = accountNumber } - private func deassociateAccountToken(_ payment: SKPayment) -> String? { - assert(Thread.isMainThread) + /// Remove association between the payment object and the account number. + /// + /// Since the association between account numbers and payments is not persisted, this method may consult the delegate to provide the account number to + /// credit. This can happen for dangling transactions that remain in the payment queue between the application restarts. In the future this association should be + /// solved by using `SKPaymentQueue.applicationUsername`. + /// + /// - Parameter payment: the payment object. + /// - Returns: The account number on success, otherwise `nil`. + private func deassociateAccountNumber(_ payment: SKPayment) -> String? { + dispatchPrecondition(condition: .onQueue(.main)) if let accountToken = paymentToAccountToken[payment] { paymentToAccountToken.removeValue(forKey: payment) return accountToken } else { - return classDelegate?.storePaymentManager(self, didRequestAccountTokenFor: payment) + return delegate?.storePaymentManager(self, didRequestAccountTokenFor: payment) } } + /// Validate account number. + /// + /// - Parameters: + /// - accountNumber: the account number + /// - completionHandler: completion handler invoked on main queue. The completion block Receives `nil` upon success, otherwise an error. private func validateAccount( accountNumber: String, completionHandler: @escaping (StorePaymentManagerError?) -> Void @@ -190,7 +233,7 @@ final class StorePaymentManager: NSObject, SKPaymentTransactionObserver { } accountOperation.addObserver(BackgroundObserver( - application: application, + application: backgroundTaskProvider, name: "Validate account number", cancelUponExpiration: false )) @@ -203,6 +246,13 @@ final class StorePaymentManager: NSObject, SKPaymentTransactionObserver { operationQueue.addOperation(accountOperation) } + /// Send the AppStore receipt stored on device to the backend. + /// + /// - Parameters: + /// - accountNumber: the account number to credit. + /// - forceRefresh: indicates whether the receipt should be downloaded from AppStore even when it's present on device. + /// - completionHandler: a completion handler invoked on main queue. + /// - Returns: the request cancellation token. private func sendStoreReceipt( accountNumber: String, forceRefresh: Bool, @@ -218,7 +268,7 @@ final class StorePaymentManager: NSObject, SKPaymentTransactionObserver { operation.addObserver( BackgroundObserver( - application: application, + application: backgroundTaskProvider, name: "Send AppStore receipt", cancelUponExpiration: true ) @@ -231,22 +281,24 @@ final class StorePaymentManager: NSObject, SKPaymentTransactionObserver { return operation } + /// Handles an array of StoreKit transactions. + /// - Parameter transactions: an array of transactions private func handleTransactions(_ transactions: [SKPaymentTransaction]) { transactions.forEach { transaction in handleTransaction(transaction) } } + /// Handle single StoreKit transaction. + /// - Parameter transaction: a 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")" - ) + let transactionError = transaction.error?.localizedDescription ?? "No error" + logger.error("Failed to purchase \(transaction.payment.productIdentifier): \(transactionError)") didFailPurchase(transaction: transaction) @@ -268,20 +320,21 @@ final class StorePaymentManager: NSObject, SKPaymentTransactionObserver { } } + /// Handle failed transaction by finishing it and notifying the observers. + /// + /// - Parameter transaction: the failed transaction. private func didFailPurchase(transaction: SKPaymentTransaction) { paymentQueue.finishTransaction(transaction) - let paymentFailure: StorePaymentFailure - - if let accountToken = deassociateAccountToken(transaction.payment) { - paymentFailure = StorePaymentFailure( + let paymentFailure = if let accountToken = deassociateAccountNumber(transaction.payment) { + StorePaymentFailure( transaction: transaction, payment: transaction.payment, accountNumber: accountToken, error: .storePayment(transaction.error!) ) } else { - paymentFailure = StorePaymentFailure( + StorePaymentFailure( transaction: transaction, payment: transaction.payment, accountNumber: nil, @@ -294,8 +347,32 @@ final class StorePaymentManager: NSObject, SKPaymentTransactionObserver { } } + /// Handle successful transaction that's in purchased or restored state. + /// + /// - Consults with transaction log before handling the transaction. Transactions that are already processed are removed from the payment queue, + /// observers are not notified as they had already received the corresponding events. + /// - Keeps transaction in the queue if association between transaction payment and account number cannot be established. Notifies observers with the error. + /// - Sends the AppStore receipt to backend. + /// + /// - Parameter transaction: the transaction that's in purchased or restored state. private func didFinishOrRestorePurchase(transaction: SKPaymentTransaction) { - guard let accountNumber = deassociateAccountToken(transaction.payment) else { + // Obtain transaction identifier which must be set on transactions with purchased or restored state. + guard let transactionIdentifier = transaction.transactionIdentifier else { + logger.warning("Purchased or restored transaction does not contain a transaction identifier!") + return + } + + // Check if transaction is already processed. + guard !transactionLog.contains(transactionIdentifier: transactionIdentifier) else { + logger.debug("Found transaction that is already processed.") + paymentQueue.finishTransaction(transaction) + return + } + + // Find the account number associated with the payment. + guard let accountNumber = deassociateAccountNumber(transaction.payment) else { + logger.debug("Cannot locate the account associated with the purchase. Keep transaction in the queue.") + let event = StorePaymentEvent.failure( StorePaymentFailure( transaction: transaction, @@ -311,36 +388,77 @@ final class StorePaymentManager: NSObject, SKPaymentTransactionObserver { return } + // Send the AppStore receipt to the backend. _ = sendStoreReceipt(accountNumber: accountNumber, forceRefresh: false) { result in - var event: StorePaymentEvent? + self.didSendStoreReceipt( + accountNumber: accountNumber, + transactionIdentifier: transactionIdentifier, + transaction: transaction, + result: result + ) + } + } - switch result { - case let .success(response): - self.paymentQueue.finishTransaction(transaction) + /// Handles the result of uploading the AppStore receipt to the backend. + /// + /// If the server response is successful, this function adds the transaction identifier to the transaction log to make sure that the same transaction is not + /// processed twice, then finishes the transaction. + /// + /// This is important because the call to `SKPaymentQueue.finishTransaction()` may fail, causing the same transaction to re-appear on the payment + /// queue. Since the transaction was already processed, no action needs to be performed besides another attempt to finish it and hopefully remove it from + /// the payment queue for good. + /// + /// If the server response indicates an error, then this function keeps the transaction in the payment queue in order to process it again later. + /// + /// Finally, the ``StorePaymentEvent`` is produced and dispatched to observers to notify them on the progress. + /// + /// - Parameters: + /// - accountNumber: the account number to credit + /// - transactionIdentifier: the transaction identifier + /// - transaction: the transaction object + /// - result: the result of uploading the AppStore receipt to the backend. + private func didSendStoreReceipt( + accountNumber: String, + transactionIdentifier: String, + transaction: SKPaymentTransaction, + result: Result + ) { + var event: StorePaymentEvent? - event = StorePaymentEvent.finished(StorePaymentCompletion( - transaction: transaction, - accountNumber: accountNumber, - serverResponse: response - )) + switch result { + case let .success(response): + // Save transaction identifier to transaction log to identify it later if it resurrects on the payment queue. + transactionLog.add(transactionIdentifier: transactionIdentifier) - case let .failure(error as StorePaymentManagerError): - event = StorePaymentEvent.failure(StorePaymentFailure( - transaction: transaction, - payment: transaction.payment, - accountNumber: accountNumber, - error: error - )) + // Finish transaction to remove it from the payment queue. + paymentQueue.finishTransaction(transaction) - default: - break - } + event = StorePaymentEvent.finished(StorePaymentCompletion( + transaction: transaction, + accountNumber: accountNumber, + serverResponse: response + )) - if let event { - self.observerList.forEach { observer in - observer.storePaymentManager(self, didReceiveEvent: event) - } + case let .failure(error as StorePaymentManagerError): + logger.debug("Failed to upload the receipt. Keep transaction in the queue.") + + event = StorePaymentEvent.failure(StorePaymentFailure( + transaction: transaction, + payment: transaction.payment, + accountNumber: accountNumber, + error: error + )) + + default: + break + } + + if let event { + observerList.forEach { observer in + observer.storePaymentManager(self, didReceiveEvent: event) } } } } + +// swiftlint:disable:this file_length diff --git a/ios/MullvadVPN/StorePaymentManager/StorePaymentManagerDelegate.swift b/ios/MullvadVPN/StorePaymentManager/StorePaymentManagerDelegate.swift index a98a37e8da21..8d2481474999 100644 --- a/ios/MullvadVPN/StorePaymentManager/StorePaymentManagerDelegate.swift +++ b/ios/MullvadVPN/StorePaymentManager/StorePaymentManagerDelegate.swift @@ -10,10 +10,7 @@ import Foundation import StoreKit protocol StorePaymentManagerDelegate: AnyObject { - /// Return the account token associated with the payment. + /// Return the account number associated with the payment. /// Usually called for unfinished transactions coming back after the app was restarted. - func storePaymentManager( - _ manager: StorePaymentManager, - didRequestAccountTokenFor payment: SKPayment - ) -> String? + func storePaymentManager(_ manager: StorePaymentManager, didRequestAccountTokenFor payment: SKPayment) -> String? } diff --git a/ios/MullvadVPN/StorePaymentManager/StoreTransactionLog.swift b/ios/MullvadVPN/StorePaymentManager/StoreTransactionLog.swift new file mode 100644 index 000000000000..f678b39131a5 --- /dev/null +++ b/ios/MullvadVPN/StorePaymentManager/StoreTransactionLog.swift @@ -0,0 +1,145 @@ +// +// StoreTransactionLog.swift +// MullvadVPN +// +// Created by pronebird on 26/10/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import MullvadLogging + +/// Transaction log responsible for storing and querying processed transactions. +/// +/// This class is thread safe. +final class StoreTransactionLog { + private let logger = Logger(label: "StoreTransactionLog") + private var transactionIdentifiers: Set = [] + private let stateLock = NSLock() + + /// The location of the transaction log file on disk. + let fileURL: URL + + /// Default location for the transaction log. + static var defaultFileURL: URL { + let directories = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask) + let location = directories.first?.appendingPathComponent("transaction.log", isDirectory: false) + // swiftlint:disable:next force_unwrapping + return location! + } + + /// Default transaction log. + static let `default` = StoreTransactionLog(fileURL: defaultFileURL) + + /// Initialize the new transaction log. + /// + /// - Warning: Panics on attempt to initialize with a non-file URL. + /// + /// - Parameter fileURL: a file URL to the transaction log file within the local filesystem. + init(fileURL: URL) { + precondition(fileURL.isFileURL, "Only local filesystem URLs are accepted.") + self.fileURL = fileURL + } + + /// Check if transaction log contains the transaction identifier. + /// + /// - Parameter transactionIdentifier: a transaction identifier. + /// - Returns: `true` if transaction log contains such transaction identifier, otherwise `false`. + func contains(transactionIdentifier: String) -> Bool { + stateLock.withLock { + transactionIdentifiers.contains(transactionIdentifier) + } + } + + /// Add transaction identifier into transaction log. + /// + /// Automatically persists the transaction log for new transaction identifiers. Returns immediately If the transaction identifier is already present in the + /// transaction log. + /// + /// - Parameter transactionIdentifier: a transaction identifier. + func add(transactionIdentifier: String) { + stateLock.withLock { + guard !transactionIdentifiers.contains(transactionIdentifier) else { return } + + transactionIdentifiers.insert(transactionIdentifier) + persist() + } + } + + /// Read transaction log from file. + func read() { + stateLock.withLock { + do { + let serializedString = try String(contentsOf: fileURL) + transactionIdentifiers = deserialize(from: serializedString) + } catch { + switch error { + case CocoaError.fileReadNoSuchFile, CocoaError.fileNoSuchFile: + // Ignore errors pointing at missing transaction log file. + break + default: + logger.error(error: error, message: "Failed to load transaction log from disk.") + } + } + } + } + + /// Persist the transaction identifiers on disk. + /// Creates the cache directory if it doesn't exist yet. + private func persist() { + let serializedData = serialize() + + do { + try persistInner(serializedString: serializedData) + } catch CocoaError.fileNoSuchFile { + createDirectoryAndPersist(serializedString: serializedData) + } catch { + logger.error(error: error, message: "Failed to persist transaction log.") + } + } + + /// Create the cache directory, then write the transaction log. + /// - Parameter serializedString: serialized transaction log. + private func createDirectoryAndPersist(serializedString: String) { + do { + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + } catch { + logger.error( + error: error, + message: "Failed to create a directory for transaction log. Trying to persist once again." + ) + } + + do { + try persistInner(serializedString: serializedString) + } catch { + logger.error(error: error, message: "Failed to persist transaction log.") + } + } + + /// Serialize transaction log into a string. + /// - Returns: string that contains a serialized transaction log. + private func serialize() -> String { + transactionIdentifiers.joined(separator: "\n") + } + + /// Deserialize transaction log from a string. + /// - Parameter serializedString: serialized string representation of a transaction log. + /// - Returns: a set of transaction identifiers. + private func deserialize(from serializedString: String) -> Set { + let transactionIdentifiers = serializedString.split { $0.isNewline } + .map { String($0) } + + return Set(transactionIdentifiers) + } + + /// Write a list of transaction identifiers on disk. + /// Transaction identifiers are stored as one per line. + /// - Parameter serializedString: serialized transaction log + private func persistInner(serializedString: String) throws { + try serializedString.write(to: fileURL, atomically: true, encoding: .utf8) + } +}