summaryrefslogtreecommitdiffhomepage
path: root/ios
diff options
context:
space:
mode:
authorAndrej Mihajlov <and@mullvad.net>2023-10-26 17:46:36 +0200
committerAndrej Mihajlov <and@mullvad.net>2023-10-30 13:46:46 +0100
commit5053d29992b29bbcdcd683e1ae2575008111d793 (patch)
treecce32e24bb1dc2f51c5022648e7e884a954d7f14 /ios
parentede2daa893c694a5671bb94e7884756ead4896c5 (diff)
downloadmullvadvpn-5053d29992b29bbcdcd683e1ae2575008111d793.tar.xz
mullvadvpn-5053d29992b29bbcdcd683e1ae2575008111d793.zip
Introduce transaction log to prevent handling already processed transactions
Diffstat (limited to 'ios')
-rw-r--r--ios/MullvadVPN.xcodeproj/project.pbxproj6
-rw-r--r--ios/MullvadVPN/AppDelegate.swift7
-rw-r--r--ios/MullvadVPN/StorePaymentManager/StorePaymentEvent.swift22
-rw-r--r--ios/MullvadVPN/StorePaymentManager/StorePaymentManager.swift280
-rw-r--r--ios/MullvadVPN/StorePaymentManager/StorePaymentManagerDelegate.swift7
-rw-r--r--ios/MullvadVPN/StorePaymentManager/StoreTransactionLog.swift145
6 files changed, 377 insertions, 90 deletions
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj
index 8a12966bce..5d06c044b6 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 = "<group>"; };
58F3F3652AA086A400D3B0A4 /* AutoCancellingTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCancellingTask.swift; sourceTree = "<group>"; };
58F3F3692AA08E3C00D3B0A4 /* PacketTunnelProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketTunnelProvider.swift; sourceTree = "<group>"; };
+ 58F70FE42AEA707800E6890E /* StoreTransactionLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreTransactionLog.swift; sourceTree = "<group>"; };
58F7753C2AB8473200425B47 /* BlockedStateErrorMapperStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockedStateErrorMapperStub.swift; sourceTree = "<group>"; };
58F7D26427EB50A300E4D821 /* ResultOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResultOperation.swift; sourceTree = "<group>"; };
58F8AC0D25D3F8CE002BE0ED /* ProblemReportReviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProblemReportReviewViewController.swift; sourceTree = "<group>"; };
@@ -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 = "<group>";
@@ -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 9e9e1cc9c0..bd8265b50c 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 ed03a59121..7d017e9dd7 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 a5cdb6237e..3a57873e45 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<StorePaymentObserver>()
+ 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<StoreSubscription>,
completionHandler: @escaping (Result<SKProductsResponse, Error>) -> 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<REST.CreateApplePaymentResponse, Error>) -> 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<REST.CreateApplePaymentResponse, Error>
+ ) {
+ 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 a98a37e8da..8d24814749 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 0000000000..f678b39131
--- /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<String> = []
+ 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<String> {
+ 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)
+ }
+}