diff options
| author | Andrej Mihajlov <and@mullvad.net> | 2022-11-08 11:05:04 +0100 |
|---|---|---|
| committer | Andrej Mihajlov <and@mullvad.net> | 2022-11-08 11:05:04 +0100 |
| commit | b607ae0e0bec2aa1bfa237af965b1415a50483f8 (patch) | |
| tree | adc05d137c3a355b80e18335ae7902a210183bf7 | |
| parent | dfb13c23d54d201530bc4e8c9d9ade27f5b9ca43 (diff) | |
| parent | 9648250c7210de8967db8148f040be5d697cab8b (diff) | |
| download | mullvadvpn-b607ae0e0bec2aa1bfa237af965b1415a50483f8.tar.xz mullvadvpn-b607ae0e0bec2aa1bfa237af965b1415a50483f8.zip | |
Merge branch 'reduce-code-duplication'
| -rw-r--r-- | ios/MullvadVPN.xcodeproj/project.pbxproj | 4 | ||||
| -rw-r--r-- | ios/MullvadVPN/DisplayChainedError.swift | 16 | ||||
| -rw-r--r-- | ios/MullvadVPN/StorePaymentManager/SendStoreReceiptOperation.swift | 109 | ||||
| -rw-r--r-- | ios/MullvadVPN/StorePaymentManager/StorePaymentManager.swift | 166 | ||||
| -rw-r--r-- | ios/MullvadVPN/StorePaymentManager/StoreReceipt.swift | 155 |
5 files changed, 180 insertions, 270 deletions
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index c73d0c820c..3e7b6261aa 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -309,7 +309,6 @@ 58FBFBEA291622580020E046 /* MullvadREST.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 06799ABC28F98E1D00ACD94E /* MullvadREST.framework */; }; 58FBFBF1291630700020E046 /* DurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FBFBF0291630700020E046 /* DurationTests.swift */; }; 58FC040A27B3EE03001C21F0 /* TunnelMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FC040927B3EE03001C21F0 /* TunnelMonitor.swift */; }; - 58FD5BE724192A2C00112C88 /* StoreReceipt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FD5BE624192A2B00112C88 /* StoreReceipt.swift */; }; 58FD5BF024238EB300112C88 /* SKProduct+Formatting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FD5BEF24238EB300112C88 /* SKProduct+Formatting.swift */; }; 58FD5BF42428C67600112C88 /* InAppPurchaseButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FD5BF32428C67600112C88 /* InAppPurchaseButton.swift */; }; 58FEEB46260A028D00A621A8 /* GeoJSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FEEB45260A028D00A621A8 /* GeoJSON.swift */; }; @@ -802,7 +801,6 @@ 58FBFBE8291622580020E046 /* ExponentialBackoffTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExponentialBackoffTests.swift; sourceTree = "<group>"; }; 58FBFBF0291630700020E046 /* DurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DurationTests.swift; sourceTree = "<group>"; }; 58FC040927B3EE03001C21F0 /* TunnelMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelMonitor.swift; sourceTree = "<group>"; }; - 58FD5BE624192A2B00112C88 /* StoreReceipt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreReceipt.swift; sourceTree = "<group>"; }; 58FD5BEF24238EB300112C88 /* SKProduct+Formatting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SKProduct+Formatting.swift"; sourceTree = "<group>"; }; 58FD5BF32428C67600112C88 /* InAppPurchaseButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppPurchaseButton.swift; sourceTree = "<group>"; }; 58FEEB45260A028D00A621A8 /* GeoJSON.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeoJSON.swift; sourceTree = "<group>"; }; @@ -1107,7 +1105,6 @@ 5846227626E22A7C0035F7C2 /* StorePaymentManagerDelegate.swift */, 58FB865426E8BF3100F188BC /* StorePaymentManagerError.swift */, 5846227226E22A160035F7C2 /* StorePaymentObserver.swift */, - 58FD5BE624192A2B00112C88 /* StoreReceipt.swift */, 5846227026E229F20035F7C2 /* StoreSubscription.swift */, ); path = StorePaymentManager; @@ -2239,7 +2236,6 @@ 58677712290976FB006F721F /* SettingsInteractor.swift in Sources */, 58CE5E66224146200008646E /* LoginViewController.swift in Sources */, 58EF580B25D69D7A00AEBA94 /* ProblemReportSubmissionOverlayView.swift in Sources */, - 58FD5BE724192A2C00112C88 /* StoreReceipt.swift in Sources */, 5892A45E265FABFF00890742 /* EmptyTableViewHeaderFooterView.swift in Sources */, 580909D32876D09A0078138D /* RevokedDeviceViewController.swift in Sources */, 5835B7CC233B76CB0096D79F /* TunnelManager.swift in Sources */, diff --git a/ios/MullvadVPN/DisplayChainedError.swift b/ios/MullvadVPN/DisplayChainedError.swift index 4bfa351f4f..c1ef9b463e 100644 --- a/ios/MullvadVPN/DisplayChainedError.swift +++ b/ios/MullvadVPN/DisplayChainedError.swift @@ -113,7 +113,7 @@ extension StorePaymentManagerError: DisplayChainedError { case .noAccountSet: return NSLocalizedString( "NO_ACCOUNT_SET_ERROR", - tableName: "AppStorePaymentManager", + tableName: "StorePaymentManager", value: "Internal error: account is not set.", comment: "" ) @@ -125,7 +125,7 @@ extension StorePaymentManagerError: DisplayChainedError { return String( format: NSLocalizedString( "INVALID_ACCOUNT_ERROR", - tableName: "AppStorePaymentManager", + tableName: "StorePaymentManager", value: "Cannot add credit to invalid account.", comment: "" ), reason @@ -136,7 +136,7 @@ extension StorePaymentManagerError: DisplayChainedError { return String( format: NSLocalizedString( "VALIDATE_ACCOUNT_ERROR", - tableName: "AppStorePaymentManager", + tableName: "StorePaymentManager", value: "Failed to validate account token: %@", comment: "" ), reason @@ -147,7 +147,7 @@ extension StorePaymentManagerError: DisplayChainedError { if readReceiptError is StoreReceiptNotFound { return NSLocalizedString( "RECEIPT_NOT_FOUND_ERROR", - tableName: "AppStorePaymentManager", + tableName: "StorePaymentManager", value: "AppStore receipt is not found on disk.", comment: "" ) @@ -155,7 +155,7 @@ extension StorePaymentManagerError: DisplayChainedError { return String( format: NSLocalizedString( "REFRESH_RECEIPT_ERROR", - tableName: "AppStorePaymentManager", + tableName: "StorePaymentManager", value: "Cannot refresh the AppStore receipt: %@", comment: "" ), @@ -165,7 +165,7 @@ extension StorePaymentManagerError: DisplayChainedError { return String( format: NSLocalizedString( "READ_RECEIPT_ERROR", - tableName: "AppStorePaymentManager", + tableName: "StorePaymentManager", value: "Cannot read the AppStore receipt from disk: %@", comment: "" ), @@ -177,13 +177,13 @@ extension StorePaymentManagerError: DisplayChainedError { let reason = restError.errorChainDescription ?? "" let errorFormat = NSLocalizedString( "SEND_RECEIPT_ERROR", - tableName: "AppStorePaymentManager", + tableName: "StorePaymentManager", value: "Failed to send the receipt to server: %@", comment: "" ) let recoverySuggestion = NSLocalizedString( "SEND_RECEIPT_RECOVERY_SUGGESTION", - tableName: "AppStorePaymentManager", + tableName: "StorePaymentManager", value: "Please retry by using the \"Restore purchases\" button.", comment: "" ) diff --git a/ios/MullvadVPN/StorePaymentManager/SendStoreReceiptOperation.swift b/ios/MullvadVPN/StorePaymentManager/SendStoreReceiptOperation.swift index 0b360a7184..73e9788c2b 100644 --- a/ios/MullvadVPN/StorePaymentManager/SendStoreReceiptOperation.swift +++ b/ios/MullvadVPN/StorePaymentManager/SendStoreReceiptOperation.swift @@ -11,29 +11,32 @@ import MullvadLogging import MullvadREST import MullvadTypes import Operations +import StoreKit class SendStoreReceiptOperation: ResultOperation< REST.CreateApplePaymentResponse, StorePaymentManagerError -> { +>, SKRequestDelegate { private let apiProxy: REST.APIProxy - private let accountToken: String + private let accountNumber: String + private let forceRefresh: Bool private let receiptProperties: [String: Any]? - private var fetchReceiptTask: Cancellable? + private var refreshRequest: SKReceiptRefreshRequest? + private var submitReceiptTask: Cancellable? private let logger = Logger(label: "SendStoreReceiptOperation") init( apiProxy: REST.APIProxy, - accountToken: String, + accountNumber: String, forceRefresh: Bool, receiptProperties: [String: Any]?, completionHandler: @escaping CompletionHandler ) { self.apiProxy = apiProxy - self.accountToken = accountToken + self.accountNumber = accountNumber self.forceRefresh = forceRefresh self.receiptProperties = receiptProperties @@ -45,42 +48,98 @@ class SendStoreReceiptOperation: ResultOperation< } override func operationDidCancel() { - fetchReceiptTask?.cancel() - fetchReceiptTask = nil + refreshRequest?.cancel() + refreshRequest = nil submitReceiptTask?.cancel() submitReceiptTask = nil } override func main() { - fetchReceiptTask = StoreReceipt.fetch( - forceRefresh: forceRefresh, - receiptProperties: receiptProperties - ) { completion in - switch completion { - case let .success(receiptData): - self.sendReceipt(receiptData) + // Pull receipt from AppStore if requested. + guard !forceRefresh else { + startRefreshRequest() + return + } - case let .failure(error): + // Read AppStore receipt from disk. + do { + let data = try readReceiptFromDisk() + + sendReceipt(data) + } catch is StoreReceiptNotFound { + // Pull receipt from AppStore if it's not cached locally. + startRefreshRequest() + } catch { + logger.error( + error: error, + message: "Failed to read the AppStore receipt." + ) + finish(completion: .failure(.readReceipt(error))) + } + } + + // - MARK: SKRequestDelegate + + func requestDidFinish(_ request: SKRequest) { + dispatchQueue.async { + do { + let data = try self.readReceiptFromDisk() + + self.sendReceipt(data) + } catch { self.logger.error( error: error, - message: "Failed to fetch the AppStore receipt." + message: "Failed to read the AppStore receipt after refresh." ) self.finish(completion: .failure(.readReceipt(error))) - - case .cancelled: - self.finish(completion: .cancelled) } } } + func request(_ request: SKRequest, didFailWithError error: Error) { + dispatchQueue.async { + self.logger.error( + error: error, + message: "Failed to refresh the AppStore receipt." + ) + self.finish(completion: .failure(.readReceipt(error))) + } + } + + // MARK: - Private + + private func startRefreshRequest() { + let refreshRequest = SKReceiptRefreshRequest(receiptProperties: receiptProperties) + refreshRequest.delegate = self + refreshRequest.start() + + self.refreshRequest = refreshRequest + } + + private func readReceiptFromDisk() throws -> Data { + guard let appStoreReceiptURL = Bundle.main.appStoreReceiptURL else { + throw StoreReceiptNotFound() + } + + do { + return try Data(contentsOf: appStoreReceiptURL) + } catch let error as CocoaError + where error.code == .fileReadNoSuchFile || error.code == .fileNoSuchFile + { + throw StoreReceiptNotFound() + } catch { + throw error + } + } + private func sendReceipt(_ receiptData: Data) { submitReceiptTask = apiProxy.createApplePayment( - accountNumber: accountToken, + accountNumber: accountNumber, receiptString: receiptData, retryStrategy: .noRetry - ) { result in - switch result { + ) { completion in + switch completion { case let .success(response): self.logger.info( """ @@ -105,3 +164,9 @@ class SendStoreReceiptOperation: ResultOperation< } } } + +struct StoreReceiptNotFound: LocalizedError { + var errorDescription: String? { + return "AppStore receipt file does not exist on disk." + } +} diff --git a/ios/MullvadVPN/StorePaymentManager/StorePaymentManager.swift b/ios/MullvadVPN/StorePaymentManager/StorePaymentManager.swift index 61b626cd33..fa8963f5b6 100644 --- a/ios/MullvadVPN/StorePaymentManager/StorePaymentManager.swift +++ b/ios/MullvadVPN/StorePaymentManager/StorePaymentManager.swift @@ -13,7 +13,7 @@ import MullvadTypes import Operations import StoreKit -class StorePaymentManager: NSObject, SKPaymentTransactionObserver { +final class StorePaymentManager: NSObject, SKPaymentTransactionObserver { private enum OperationCategory { static let sendStoreReceipt = "StorePaymentManager.sendStoreReceipt" static let productsRequest = "StorePaymentManager.productsRequest" @@ -124,67 +124,38 @@ class StorePaymentManager: NSObject, SKPaymentTransactionObserver { return operation } - func addPayment(_ payment: SKPayment, for accountToken: String) { - var task: Cancellable? - let backgroundTaskIdentifier = UIApplication.shared - .beginBackgroundTask(withName: "Validate account token") { - task?.cancel() - } - + func addPayment(_ payment: SKPayment, for accountNumber: String) { // Validate account token before adding new payment to the queue. - task = accountsProxy.getAccountData( - accountNumber: accountToken, - retryStrategy: .default - ) { completion in - dispatchPrecondition(condition: .onQueue(.main)) - - switch completion { - case .success: - self.associateAccountToken(accountToken, and: payment) - self.paymentQueue.add(payment) - - case let .failure(error): - let event = StorePaymentEvent.failure( - StorePaymentFailure( - transaction: nil, - payment: payment, - accountNumber: accountToken, - error: .validateAccount(error) - ) - ) - - self.observerList.forEach { observer in - observer.storePaymentManager(self, didReceiveEvent: event) - } - - case .cancelled: + validateAccount(accountNumber: accountNumber) { error in + if let error = error { let event = StorePaymentEvent.failure( StorePaymentFailure( transaction: nil, payment: payment, - accountNumber: accountToken, - error: .validateAccount(.network(URLError(.cancelled))) + accountNumber: accountNumber, + error: error ) ) self.observerList.forEach { observer in observer.storePaymentManager(self, didReceiveEvent: event) } + } else { + self.associateAccountToken(accountNumber, and: payment) + self.paymentQueue.add(payment) } - - UIApplication.shared.endBackgroundTask(backgroundTaskIdentifier) } } func restorePurchases( - for accountToken: String, + for accountNumber: String, completionHandler: @escaping (OperationCompletion< REST.CreateApplePaymentResponse, StorePaymentManagerError >) -> Void ) -> Cancellable { return sendStoreReceipt( - accountToken: accountToken, + accountNumber: accountNumber, forceRefresh: true, completionHandler: completionHandler ) @@ -209,18 +180,59 @@ class StorePaymentManager: NSObject, SKPaymentTransactionObserver { } } + private func validateAccount( + accountNumber: String, + completionHandler: @escaping (StorePaymentManagerError?) -> Void + ) { + let accountOperation = ResultBlockOperation< + REST.AccountData, + REST.Error + >(dispatchQueue: .main) { op in + let task = self.accountsProxy.getAccountData( + accountNumber: accountNumber, + retryStrategy: .default + ) { completion in + op.finish(completion: completion) + } + + op.addCancellationBlock { + task.cancel() + } + } + + accountOperation.addObserver(BackgroundObserver( + application: .shared, + name: "Validate account number", + cancelUponExpiration: false + )) + + accountOperation.completionQueue = .main + accountOperation.completionHandler = { completion in + var error: REST.Error? + + if case .cancelled = completion { + error = .network(URLError(.cancelled)) + } else { + error = completion.error + } + + completionHandler(error.map { .validateAccount($0) }) + } + + operationQueue.addOperation(accountOperation) + } + private func sendStoreReceipt( - accountToken: String, + accountNumber: String, forceRefresh: Bool, completionHandler: @escaping (OperationCompletion< REST.CreateApplePaymentResponse, StorePaymentManagerError - >) - -> Void + >) -> Void ) -> Cancellable { let operation = SendStoreReceiptOperation( apiProxy: apiProxy, - accountToken: accountToken, + accountNumber: accountNumber, forceRefresh: forceRefresh, receiptProperties: nil, completionHandler: completionHandler @@ -234,9 +246,7 @@ class StorePaymentManager: NSObject, SKPaymentTransactionObserver { ) ) - operation.addCondition( - MutuallyExclusive(category: OperationCategory.sendStoreReceipt) - ) + operation.addCondition(MutuallyExclusive(category: OperationCategory.sendStoreReceipt)) operationQueue.addOperation(operation) @@ -283,37 +293,31 @@ class StorePaymentManager: NSObject, SKPaymentTransactionObserver { private func didFailPurchase(transaction: SKPaymentTransaction) { paymentQueue.finishTransaction(transaction) + let paymentFailure: StorePaymentFailure + if let accountToken = deassociateAccountToken(transaction.payment) { - let event = StorePaymentEvent.failure( - StorePaymentFailure( - transaction: transaction, - payment: transaction.payment, - accountNumber: accountToken, - error: .storePayment(transaction.error!) - ) + paymentFailure = StorePaymentFailure( + transaction: transaction, + payment: transaction.payment, + accountNumber: accountToken, + error: .storePayment(transaction.error!) ) - - observerList.forEach { observer in - observer.storePaymentManager(self, didReceiveEvent: event) - } } else { - let event = StorePaymentEvent.failure( - StorePaymentFailure( - transaction: transaction, - payment: transaction.payment, - accountNumber: nil, - error: .noAccountSet - ) + paymentFailure = StorePaymentFailure( + transaction: transaction, + payment: transaction.payment, + accountNumber: nil, + error: .noAccountSet ) + } - observerList.forEach { observer in - observer.storePaymentManager(self, didReceiveEvent: event) - } + observerList.forEach { observer in + observer.storePaymentManager(self, didReceiveEvent: .failure(paymentFailure)) } } private func didFinishOrRestorePurchase(transaction: SKPaymentTransaction) { - guard let accountToken = deassociateAccountToken(transaction.payment) else { + guard let accountNumber = deassociateAccountToken(transaction.payment) else { let event = StorePaymentEvent.failure( StorePaymentFailure( transaction: transaction, @@ -329,35 +333,35 @@ class StorePaymentManager: NSObject, SKPaymentTransactionObserver { return } - _ = sendStoreReceipt(accountToken: accountToken, forceRefresh: false) { completion in + _ = sendStoreReceipt(accountNumber: accountNumber, forceRefresh: false) { completion in + var event: StorePaymentEvent? + switch completion { case let .success(response): self.paymentQueue.finishTransaction(transaction) - let event = StorePaymentEvent.finished(StorePaymentCompletion( + event = StorePaymentEvent.finished(StorePaymentCompletion( transaction: transaction, - accountNumber: accountToken, + accountNumber: accountNumber, serverResponse: response )) - self.observerList.forEach { observer in - observer.storePaymentManager(self, didReceiveEvent: event) - } - case let .failure(error): - let event = StorePaymentEvent.failure(StorePaymentFailure( + event = StorePaymentEvent.failure(StorePaymentFailure( transaction: transaction, payment: transaction.payment, - accountNumber: accountToken, + accountNumber: accountNumber, error: error )) + case .cancelled: + break + } + + if let event = event { self.observerList.forEach { observer in observer.storePaymentManager(self, didReceiveEvent: event) } - - case .cancelled: - break } } } diff --git a/ios/MullvadVPN/StorePaymentManager/StoreReceipt.swift b/ios/MullvadVPN/StorePaymentManager/StoreReceipt.swift deleted file mode 100644 index 93731ba7f9..0000000000 --- a/ios/MullvadVPN/StorePaymentManager/StoreReceipt.swift +++ /dev/null @@ -1,155 +0,0 @@ -// -// StoreReceipt.swift -// MullvadVPN -// -// Created by pronebird on 11/03/2020. -// Copyright © 2020 Mullvad VPN AB. All rights reserved. -// - -import Foundation -import MullvadREST -import MullvadTypes -import Operations -import StoreKit - -struct StoreReceiptNotFound: LocalizedError { - var errorDescription: String? { - return "AppStore receipt file does not exist on disk." - } -} - -enum StoreReceipt { - /// Internal operation queue. - private static let operationQueue: OperationQueue = { - let queue = AsyncOperationQueue() - queue.name = "StoreReceiptQueue" - queue.maxConcurrentOperationCount = 1 - return queue - }() - - /// Read AppStore receipt from disk or refresh it from AppStore if it's missing. - /// This call may trigger a sign in with AppStore prompt to appear. - static func fetch( - forceRefresh: Bool = false, - receiptProperties: [String: Any]? = nil, - completionHandler: @escaping (OperationCompletion<Data, Error>) -> Void - ) -> Cancellable { - let operation = FetchAppStoreReceiptOperation( - forceRefresh: forceRefresh, - receiptProperties: receiptProperties, - completionHandler: completionHandler - ) - - operation.addObserver( - BackgroundObserver( - application: .shared, - name: "Fetch AppStore receipt", - cancelUponExpiration: true - ) - ) - - operationQueue.addOperation(operation) - - return operation - } -} - -private class FetchAppStoreReceiptOperation: ResultOperation<Data, Error>, SKRequestDelegate { - private var request: SKReceiptRefreshRequest? - private let receiptProperties: [String: Any]? - private let forceRefresh: Bool - - init( - forceRefresh: Bool, - receiptProperties: [String: Any]?, - completionHandler: @escaping (Completion) -> Void - ) { - self.forceRefresh = forceRefresh - self.receiptProperties = receiptProperties - - super.init( - dispatchQueue: .main, - completionQueue: .main, - completionHandler: completionHandler - ) - } - - override func main() { - // Pull receipt from AppStore if requested. - guard !forceRefresh else { - startRefreshRequest() - return - } - - // Read AppStore receipt from disk. - do { - let data = try readReceiptFromDisk() - - finish(completion: .success(data)) - } catch is StoreReceiptNotFound { - // Pull receipt from AppStore if it's not cached locally. - startRefreshRequest() - } catch { - finish(completion: .failure(error)) - } - } - - override func operationDidCancel() { - request?.cancel() - } - - // - MARK: SKRequestDelegate - - func requestDidFinish(_ request: SKRequest) { - dispatchQueue.async { - self.didFinishRefreshRequest(error: nil) - } - } - - func request(_ request: SKRequest, didFailWithError error: Error) { - dispatchQueue.async { - self.didFinishRefreshRequest(error: error) - } - } - - // - MARK: Private - - private func startRefreshRequest() { - let request = SKReceiptRefreshRequest(receiptProperties: receiptProperties) - request.delegate = self - request.start() - - self.request = request - } - - private func didFinishRefreshRequest(error: Error?) { - guard !isCancelled else { - finish(completion: .cancelled) - return - } - - if let error = error { - finish(completion: .failure(error)) - } else { - let result = Result { try readReceiptFromDisk() } - - finish(completion: OperationCompletion(result: result)) - } - } - - private func readReceiptFromDisk() throws -> Data { - guard let appStoreReceiptURL = Bundle.main.appStoreReceiptURL else { - throw StoreReceiptNotFound() - } - - do { - return try Data(contentsOf: appStoreReceiptURL) - } catch let error as CocoaError - where error.code == .fileReadNoSuchFile || error.code == .fileNoSuchFile - { - throw StoreReceiptNotFound() - } catch { - throw error - } - } -} |
