diff options
| -rw-r--r-- | ios/MullvadVPN.xcodeproj/project.pbxproj | 18 | ||||
| -rw-r--r-- | ios/MullvadVPN/Account.swift | 24 | ||||
| -rw-r--r-- | ios/MullvadVPN/AccountViewController.swift | 202 | ||||
| -rw-r--r-- | ios/MullvadVPN/AppStorePaymentManager.swift | 302 | ||||
| -rw-r--r-- | ios/MullvadVPN/AppStorePaymentPublisher.swift | 87 | ||||
| -rw-r--r-- | ios/MullvadVPN/AppStoreReceipt.swift | 97 | ||||
| -rw-r--r-- | ios/MullvadVPN/DisplayChainedError.swift | 36 | ||||
| -rw-r--r-- | ios/MullvadVPN/SKPaymentQueuePublisher.swift | 71 | ||||
| -rw-r--r-- | ios/MullvadVPN/SKRequestPublisher.swift | 121 | ||||
| -rw-r--r-- | ios/MullvadVPN/UIAlertController+Error.swift | 52 | ||||
| -rw-r--r-- | ios/MullvadVPN/UserInterfaceInteractionRestriction.swift | 48 |
11 files changed, 432 insertions, 626 deletions
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index b0e038f11b..92961b6592 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -109,7 +109,6 @@ 589AB4F7227B64450039131E /* BasicTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 589AB4F6227B64450039131E /* BasicTableViewCell.swift */; }; 58A1AA8C23F5584C009F7EA6 /* ConnectionPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58A1AA8B23F5584B009F7EA6 /* ConnectionPanelView.swift */; }; 58A8BE81239FBE62006B74AC /* IPEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58561C98239A5D1500BD6B5E /* IPEndpoint.swift */; }; - 58A8BE8323A0F362006B74AC /* UIAlertController+Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58A8BE8223A0F362006B74AC /* UIAlertController+Error.swift */; }; 58A99ED3240014A0006599E9 /* ConsentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58A99ED2240014A0006599E9 /* ConsentViewController.swift */; }; 58ADDB3C227B1BD200FAFEA7 /* JsonRpc.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58ADDB3B227B1BD200FAFEA7 /* JsonRpc.swift */; }; 58ADDB3E227B1CD900FAFEA7 /* MullvadRpc.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58ADDB3D227B1CD900FAFEA7 /* MullvadRpc.swift */; }; @@ -193,12 +192,9 @@ 58FAEE0324533ABE00CB0F5B /* KeychainReturn.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FAEDFE24533A7000CB0F5B /* KeychainReturn.swift */; }; 58FAEE0424533AC000CB0F5B /* KeychainClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FAEE0024533A9C00CB0F5B /* KeychainClass.swift */; }; 58FD5BE724192A2C00112C88 /* AppStoreReceipt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FD5BE624192A2B00112C88 /* AppStoreReceipt.swift */; }; - 58FD5BE92419406000112C88 /* SKRequestPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FD5BE82419406000112C88 /* SKRequestPublisher.swift */; }; - 58FD5BEC2420F58A00112C88 /* SKPaymentQueuePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FD5BEB2420F58A00112C88 /* SKPaymentQueuePublisher.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 */; }; 58FD5BF42428C67600112C88 /* InAppPurchaseButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FD5BF32428C67600112C88 /* InAppPurchaseButton.swift */; }; - 58FD5BF624291F1A00112C88 /* AppStorePaymentPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FD5BF524291F1A00112C88 /* AppStorePaymentPublisher.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -308,7 +304,6 @@ 589AB4F6227B64450039131E /* BasicTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicTableViewCell.swift; sourceTree = "<group>"; }; 58A1AA8623F43901009F7EA6 /* Location.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Location.swift; sourceTree = "<group>"; }; 58A1AA8B23F5584B009F7EA6 /* ConnectionPanelView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConnectionPanelView.swift; sourceTree = "<group>"; }; - 58A8BE8223A0F362006B74AC /* UIAlertController+Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIAlertController+Error.swift"; sourceTree = "<group>"; }; 58A99ED2240014A0006599E9 /* ConsentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsentViewController.swift; sourceTree = "<group>"; }; 58ADDB3B227B1BD200FAFEA7 /* JsonRpc.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JsonRpc.swift; sourceTree = "<group>"; }; 58ADDB3D227B1CD900FAFEA7 /* MullvadRpc.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MullvadRpc.swift; sourceTree = "<group>"; }; @@ -374,12 +369,9 @@ 58FBDAA422A52BDA00EB69A3 /* PacketTunnel-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "PacketTunnel-Bridging-Header.h"; sourceTree = "<group>"; }; 58FBDAAA22A52DC500EB69A3 /* MullvadVPN-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "MullvadVPN-Bridging-Header.h"; sourceTree = "<group>"; }; 58FD5BE624192A2B00112C88 /* AppStoreReceipt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreReceipt.swift; sourceTree = "<group>"; }; - 58FD5BE82419406000112C88 /* SKRequestPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SKRequestPublisher.swift; sourceTree = "<group>"; }; - 58FD5BEB2420F58A00112C88 /* SKPaymentQueuePublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SKPaymentQueuePublisher.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>"; }; 58FD5BF32428C67600112C88 /* InAppPurchaseButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppPurchaseButton.swift; sourceTree = "<group>"; }; - 58FD5BF524291F1A00112C88 /* AppStorePaymentPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStorePaymentPublisher.swift; sourceTree = "<group>"; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -499,7 +491,6 @@ 58CE5E63224146200008646E /* AppDelegate.swift */, 58BFA5CB22A7CE1F00A6173D /* ApplicationConfiguration.swift */, 58DF28A42417CB4B00E836B0 /* AppStorePaymentManager.swift */, - 58FD5BF524291F1A00112C88 /* AppStorePaymentPublisher.swift */, 58FD5BE624192A2B00112C88 /* AppStoreReceipt.swift */, 58CE5E6A224146210008646E /* Assets.xcassets */, 5845F839236C6A7200B2D93C /* AutoDisposableSink.swift */, @@ -512,6 +503,7 @@ 58CCA00F224249A1004F3011 /* ConnectViewController.swift */, 58A99ED2240014A0006599E9 /* ConsentViewController.swift */, 58F3C09B249B99DD003E76BE /* Curve25519.swift */, + 5896AE83246D5889005B36CB /* CustomDateComponentsFormatting.swift */, 582BB1B0229569620055B6EF /* CustomNavigationBar.swift */, 58C6B35D22BBBFE3003C19AD /* Data+HexCoding.swift */, 58B9EB142489139B00095626 /* DisplayChainedError.swift */, @@ -564,16 +556,13 @@ 58CCA01122424D11004F3011 /* SettingsViewController.swift */, 58BA693023EADA6A009DC256 /* SimulatorTunnelProvider.swift */, 587A01FB23F1F0BE00B68763 /* SimulatorTunnelProviderHost.swift */, - 58FD5BEB2420F58A00112C88 /* SKPaymentQueuePublisher.swift */, 58FD5BEF24238EB300112C88 /* SKProduct+Formatting.swift */, - 58FD5BE82419406000112C88 /* SKRequestPublisher.swift */, 58F19E34228C15BA00C7710B /* SpinnerActivityIndicatorView.swift */, 581CBCED229826FD00727D7F /* StaticTableViewDataSource.swift */, 5807E2BF2432038B00F5FF30 /* String+Split.swift */, 5862805322428EF100F5A6E1 /* TranslucentButtonBlurView.swift */, 5845F837236C466400B2D93C /* TunnelControlViewController.swift */, 5835B7CB233B76CB0096D79F /* TunnelManager.swift */, - 58A8BE8223A0F362006B74AC /* UIAlertController+Error.swift */, 587AD7C523421D7000E93A53 /* TunnelSettings.swift */, 58AEEF6A2344A46200C9BBD5 /* TunnelSettingsManager.swift */, 587CBFE222807F530028DED3 /* UIColor+Helpers.swift */, @@ -583,7 +572,6 @@ 58B8743122B25A7600015324 /* WireguardAssociatedAddresses.swift */, 5877152F23981F7B001F8237 /* WireguardKeysViewController.swift */, 58C6B35322BB87C4003C19AD /* WireguardPrivateKey.swift */, - 5896AE83246D5889005B36CB /* CustomDateComponentsFormatting.swift */, 58F3C098249B978C003E76BE /* x25519.c */, 58F3C097249B978C003E76BE /* x25519.h */, ); @@ -911,7 +899,6 @@ 58BA693123EADA6A009DC256 /* SimulatorTunnelProvider.swift in Sources */, 58E6771F24ADFE7800AA26E7 /* SettingsNavigationController.swift in Sources */, 58A1AA8C23F5584C009F7EA6 /* ConnectionPanelView.swift in Sources */, - 58FD5BE92419406000112C88 /* SKRequestPublisher.swift in Sources */, 582BB1B52295780F0055B6EF /* AccountExpiry.swift in Sources */, 582BB1B3229574F40055B6EF /* SettingsAccountCell.swift in Sources */, 58CCA010224249A1004F3011 /* ConnectViewController.swift in Sources */, @@ -940,7 +927,6 @@ 58FAEDFD24533A5500CB0F5B /* KeychainMatchLimit.swift in Sources */, 5845F83A236C6A7200B2D93C /* AutoDisposableSink.swift in Sources */, 5840250422B11AB700E4CFEC /* MullvadEndpoint.swift in Sources */, - 58FD5BEC2420F58A00112C88 /* SKPaymentQueuePublisher.swift in Sources */, 58CC40EF24A601900019D96E /* ObserverList.swift in Sources */, 58CCA01822426713004F3011 /* AccountViewController.swift in Sources */, 58F3C099249B978C003E76BE /* x25519.c in Sources */, @@ -984,9 +970,7 @@ 5888AD7F2279B6BF0051EB06 /* RelayStatusIndicatorView.swift in Sources */, 5867A51C2248F26A005513C0 /* SegueIdentifier.swift in Sources */, 58CCA01E2242787B004F3011 /* AccountTextField.swift in Sources */, - 58FD5BF624291F1A00112C88 /* AppStorePaymentPublisher.swift in Sources */, 587AD7CA2342283900E93A53 /* Account.swift in Sources */, - 58A8BE8323A0F362006B74AC /* UIAlertController+Error.swift in Sources */, 58F840AF2464382C0044E708 /* KeychainItemRevision.swift in Sources */, 587425C12299833500CA2045 /* RootContainerViewController.swift in Sources */, 580EE20124B321D500F9D8A1 /* OperationProtocol.swift in Sources */, diff --git a/ios/MullvadVPN/Account.swift b/ios/MullvadVPN/Account.swift index 300e5d277b..574a2af656 100644 --- a/ios/MullvadVPN/Account.swift +++ b/ios/MullvadVPN/Account.swift @@ -223,17 +223,25 @@ extension Account: AppStorePaymentObserver { paymentManager.addPaymentObserver(self) } - func appStorePaymentManager(_ manager: AppStorePaymentManager, transaction: SKPaymentTransaction, didFailWithError error: AppStorePaymentManager.Error) { + func appStorePaymentManager(_ manager: AppStorePaymentManager, transaction: SKPaymentTransaction, accountToken: String?, didFailWithError error: AppStorePaymentManager.Error) { // no-op } - func appStorePaymentManager(_ manager: AppStorePaymentManager, transaction: SKPaymentTransaction, didFinishWithResponse response: SendAppStoreReceiptResponse) { - UserDefaults.standard.set(response.newExpiry, - forKey: UserDefaultsKeys.accountExpiry.rawValue) + func appStorePaymentManager(_ manager: AppStorePaymentManager, transaction: SKPaymentTransaction, accountToken: String, didFinishWithResponse response: SendAppStoreReceiptResponse) { + let newExpiry = response.newExpiry - NotificationCenter.default.post( - name: Self.didUpdateAccountExpiryNotification, - object: self, userInfo: [Self.newAccountExpiryUserInfoKey: response.newExpiry] - ) + let operation = AsyncBlockOperation { (finish) in + DispatchQueue.main.async { + // Make sure that payment corresponds to the active account token + if self.token == accountToken { + self.expiry = newExpiry + self.postExpiryUpdateNotification(newExpiry: newExpiry) + } + + finish() + } + } + + exclusivityController.addOperation(operation, categories: [.exclusive]) } } diff --git a/ios/MullvadVPN/AccountViewController.swift b/ios/MullvadVPN/AccountViewController.swift index 0f0d300ba6..6d3bf1dbcf 100644 --- a/ios/MullvadVPN/AccountViewController.swift +++ b/ios/MullvadVPN/AccountViewController.swift @@ -6,12 +6,11 @@ // Copyright © 2019 Mullvad VPN AB. All rights reserved. // -import Combine import StoreKit import UIKit import os -class AccountViewController: UIViewController { +class AccountViewController: UIViewController, AppStorePaymentObserver { @IBOutlet var accountTokenButton: UIButton! @IBOutlet var purchaseButton: InAppPurchaseButton! @@ -20,16 +19,14 @@ class AccountViewController: UIViewController { @IBOutlet var expiryLabel: UILabel! @IBOutlet var activityIndicator: SpinnerActivityIndicatorView! - private var accountExpirySubscriber: AnyCancellable? - private var logoutSubscriber: AnyCancellable? - private var copyToPasteboardSubscriber: AnyCancellable? - private var requestProductsSubscriber: AnyCancellable? - private var purchaseSubscriber: AnyCancellable? - private var restorePurchasesSubscriber: AnyCancellable? + private var copyToPasteboardWork: DispatchWorkItem? + private var accountExpiryObserver: NSObjectProtocol? + + private var pendingPayment: SKPayment? + private let alertPresenter = AlertPresenter() private lazy var purchaseButtonInteractionRestriction = - UserInterfaceInteractionRestriction(scheduler: DispatchQueue.main) { - [weak self] (enableUserInteraction, _) in + UserInterfaceInteractionRestriction { [weak self] (enableUserInteraction, _) in // Make sure to disable the button if the product is not loaded self?.purchaseButton.isEnabled = enableUserInteraction && self?.product != nil && @@ -37,8 +34,7 @@ class AccountViewController: UIViewController { } private lazy var viewControllerInteractionRestriction = - UserInterfaceInteractionRestriction(scheduler: DispatchQueue.main) { - [weak self] (enableUserInteraction, animated) in + UserInterfaceInteractionRestriction { [weak self] (enableUserInteraction, animated) in self?.setEnableUserInteraction(enableUserInteraction, animated: true) } @@ -53,11 +49,13 @@ class AccountViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - accountExpirySubscriber = NotificationCenter.default - .publisher(for: Account.didUpdateAccountExpiryNotification, object: Account.shared) - .receive(on: DispatchQueue.main) - .sink { [weak self] (notification) in - guard let newExpiryDate = notification + AppStorePaymentManager.shared.addPaymentObserver(self) + + accountExpiryObserver = NotificationCenter.default.addObserver( + forName: Account.didUpdateAccountExpiryNotification, + object: Account.shared, + queue: OperationQueue.main) { [weak self] (note) in + guard let newExpiryDate = note .userInfo?[Account.newAccountExpiryUserInfoKey] as? Date else { return } self?.updateAccountExpiry(expiryDate: newExpiryDate) @@ -97,21 +95,26 @@ class AccountViewController: UIViewController { purchaseButton.setTitle(inAppPurchase.localizedTitle, for: .normal) purchaseButton.isLoading = true - requestProductsSubscriber = AppStorePaymentManager.shared.requestProducts(with: [inAppPurchase]) - .retry(10) - .receive(on: DispatchQueue.main) - .restrictUserInterfaceInteraction(with: self.purchaseButtonInteractionRestriction, animated: true) - .sink(receiveCompletion: { [weak self] (completion) in - if case .failure(let error) = completion { - self?.didFailLoadingProducts(with: error) - } + purchaseButtonInteractionRestriction.increase(animated: true) - self?.purchaseButton.isLoading = false - }, receiveValue: { [weak self] (response) in + AppStorePaymentManager.shared.requestProducts(with: [inAppPurchase]) { [weak self] (result) in + DispatchQueue.main.async { + guard let self = self else { return } + + switch result { + case .success(let response): if let product = response.products.first { - self?.setProduct(product, animated: true) + self.setProduct(product, animated: true) } - }) + + case .failure(let error): + self.didFailLoadingProducts(with: error) + } + + self.purchaseButton.isLoading = false + self.purchaseButtonInteractionRestriction.decrease(animated: true) + } + } } private func setProduct(_ product: SKProduct, animated: Bool) { @@ -179,7 +182,7 @@ class AccountViewController: UIViewController { ) alertController.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: ""), style: .cancel)) - present(alertController, animated: true) + alertPresenter.enqueue(alertController, presentingController: self) } private func showLogoutConfirmation(completion: @escaping (Bool) -> Void, animated: Bool) { @@ -211,7 +214,7 @@ class AccountViewController: UIViewController { }) ) - present(alertController, animated: animated) + alertPresenter.enqueue(alertController, presentingController: self) } private func confirmLogout() { @@ -223,25 +226,69 @@ class AccountViewController: UIViewController { message: message, preferredStyle: .alert) - present(alertController, animated: true) { - self.logoutSubscriber = Account.shared.logout() - .delay(for: .seconds(1), scheduler: DispatchQueue.main) - .sink(receiveCompletion: { (completion) in - switch completion { - case .failure(let error): - alertController.dismiss(animated: true) { - self.presentError(error, preferredStyle: .alert) - } + alertPresenter.enqueue(alertController, presentingController: self) { + Account.shared.logout { (result) in + DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) { + alertController.dismiss(animated: true) { + switch result { + case .failure(let error): + error.logChain(message: "Failed to log out") - case .finished: - self.performSegue( - withIdentifier: SegueIdentifier.Account.logout.rawValue, - sender: self) + let errorAlertController = UIAlertController( + title: NSLocalizedString("Failed to log out", comment: ""), + message: error.errorChainDescription, + preferredStyle: .alert + ) + errorAlertController.addAction( + UIAlertAction(title: NSLocalizedString("OK", comment: ""), style: .cancel) + ) + self.alertPresenter.enqueue(errorAlertController, presentingController: self) + + case .success: + self.performSegue( + withIdentifier: SegueIdentifier.Account.logout.rawValue, + sender: self + ) + } } - }) + } + } + } + } + + // MARK: - AppStorePaymentObserver + + func appStorePaymentManager(_ manager: AppStorePaymentManager, transaction: SKPaymentTransaction, accountToken: String?, didFailWithError error: AppStorePaymentManager.Error) { + DispatchQueue.main.async { + let alertController = UIAlertController( + title: NSLocalizedString("Cannot complete the purchase", comment: ""), + message: error.errorChainDescription, + preferredStyle: .alert + ) + + alertController.addAction( + UIAlertAction(title: NSLocalizedString("OK", comment: ""), style: .cancel) + ) + + self.alertPresenter.enqueue(alertController, presentingController: self) + + if transaction.payment == self.pendingPayment { + self.compoundInteractionRestriction.decrease(animated: true) + } } } + func appStorePaymentManager(_ manager: AppStorePaymentManager, transaction: SKPaymentTransaction, accountToken: String, didFinishWithResponse response: SendAppStoreReceiptResponse) { + DispatchQueue.main.async { + self.showTimeAddedConfirmationAlert(with: response, context: .purchase) + + if transaction.payment == self.pendingPayment { + self.compoundInteractionRestriction.decrease(animated: true) + } + } + } + + // MARK: - Actions @IBAction func doLogout() { @@ -259,44 +306,53 @@ class AccountViewController: UIViewController { NSLocalizedString("COPIED TO PASTEBOARD!", comment: ""), for: .normal) - copyToPasteboardSubscriber = - Just(Account.shared.formattedToken) - .cancellableDelay(for: .seconds(3), scheduler: DispatchQueue.main) - .sink(receiveValue: { [weak self] (accountToken) in - self?.accountTokenButton.setTitle(accountToken, for: .normal) - }) + let dispatchWork = DispatchWorkItem { [weak self] in + self?.accountTokenButton.setTitle(Account.shared.formattedToken, for: .normal) + } + + DispatchQueue.main.asyncAfter(wallDeadline: .now() + .seconds(3), execute: dispatchWork) + + self.copyToPasteboardWork?.cancel() + self.copyToPasteboardWork = dispatchWork } @IBAction func doPurchase() { - guard let product = product else { return } + guard let product = product, let accountToken = Account.shared.token else { return } let payment = SKPayment(product: product) + self.pendingPayment = payment - purchaseSubscriber = AppStorePaymentManager.shared - .addPayment(payment, for: Account.shared.token!) - .receive(on: DispatchQueue.main) - .restrictUserInterfaceInteraction(with: compoundInteractionRestriction, animated: true) - .sink(receiveCompletion: { [weak self] (completion) in - if case .failure(let error) = completion { - self?.presentError(error, preferredStyle: .alert) - } - }, receiveValue: { [weak self] (response) in - self?.showTimeAddedConfirmationAlert(with: response, context: .purchase) - }) + compoundInteractionRestriction.increase(animated: true) + + AppStorePaymentManager.shared.addPayment(payment, for: accountToken) } @IBAction func restorePurchases() { - restorePurchasesSubscriber = AppStorePaymentManager.shared - .restorePurchases(for: Account.shared.token!) - .receive(on: DispatchQueue.main) - .restrictUserInterfaceInteraction(with: compoundInteractionRestriction, animated: true) - .sink(receiveCompletion: { [weak self] (completion) in - if case .failure(let error) = completion { - self?.presentError(error, preferredStyle: .alert) + guard let accountToken = Account.shared.token else { return } + + compoundInteractionRestriction.increase(animated: true) + + AppStorePaymentManager.shared.restorePurchases(for: accountToken) { (result) in + DispatchQueue.main.async { + switch result { + case .success(let response): + self.showTimeAddedConfirmationAlert(with: response, context: .restoration) + + case .failure(let error): + let alertController = UIAlertController( + title: NSLocalizedString("Cannot restore purchases", comment: ""), + message: error.errorChainDescription, + preferredStyle: .alert + ) + alertController.addAction( + UIAlertAction(title: NSLocalizedString("OK", comment: ""), style: .cancel) + ) + self.alertPresenter.enqueue(alertController, presentingController: self) } - }, receiveValue: { [weak self] (response) in - self?.showTimeAddedConfirmationAlert(with: response, context: .restoration) - }) + + self.compoundInteractionRestriction.decrease(animated: true) + } + } } } diff --git a/ios/MullvadVPN/AppStorePaymentManager.swift b/ios/MullvadVPN/AppStorePaymentManager.swift index fb1c2f51cb..55a22f8cae 100644 --- a/ios/MullvadVPN/AppStorePaymentManager.swift +++ b/ios/MullvadVPN/AppStorePaymentManager.swift @@ -6,7 +6,6 @@ // Copyright © 2020 Mullvad VPN AB. All rights reserved. // -import Combine import Foundation import StoreKit import os @@ -39,38 +38,51 @@ protocol AppStorePaymentObserver: class { func appStorePaymentManager( _ manager: AppStorePaymentManager, transaction: SKPaymentTransaction, + accountToken: String?, didFailWithError error: AppStorePaymentManager.Error) func appStorePaymentManager( _ manager: AppStorePaymentManager, transaction: SKPaymentTransaction, + accountToken: String, didFinishWithResponse response: SendAppStoreReceiptResponse) } /// A type-erasing weak container for `AppStorePaymentObserver` -private class WeakAnyAppStorePaymentObserver: AppStorePaymentObserver { +private class AnyAppStorePaymentObserver: WeakObserverBox, Equatable { private(set) weak var inner: AppStorePaymentObserver? - init(_ inner: AppStorePaymentObserver) { + init<T: AppStorePaymentObserver>(_ inner: T) { self.inner = inner } func appStorePaymentManager(_ manager: AppStorePaymentManager, transaction: SKPaymentTransaction, + accountToken: String?, didFailWithError error: AppStorePaymentManager.Error) { - inner?.appStorePaymentManager(manager, transaction: transaction, didFailWithError: error) + self.inner?.appStorePaymentManager( + manager, + transaction: transaction, + accountToken: accountToken, + didFailWithError: error) } func appStorePaymentManager(_ manager: AppStorePaymentManager, transaction: SKPaymentTransaction, + accountToken: String, didFinishWithResponse response: SendAppStoreReceiptResponse) { - inner?.appStorePaymentManager(manager, - transaction: transaction, - didFinishWithResponse: response) + self.inner?.appStorePaymentManager( + manager, + transaction: transaction, + accountToken: accountToken, + didFinishWithResponse: response) } + static func == (lhs: AnyAppStorePaymentObserver, rhs: AnyAppStorePaymentObserver) -> Bool { + return lhs.inner === rhs.inner + } } protocol AppStorePaymentManagerDelegate: class { @@ -81,29 +93,36 @@ protocol AppStorePaymentManagerDelegate: class { didRequestAccountTokenFor payment: SKPayment) -> String? } -class AppStorePaymentManager { - - enum SendAppStoreReceiptError: Swift.Error { - case read(AppStoreReceipt.Error) - case rpc(MullvadRpc.Error) - } +class AppStorePaymentManager: NSObject, SKPaymentTransactionObserver { - enum Error: Swift.Error { + enum Error: ChainedError { case noAccountSet case storePayment(Swift.Error) - case sendReceipt(SendAppStoreReceiptError) + case readReceipt(AppStoreReceipt.Error) + case sendReceipt(MullvadRpc.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" + } + } } /// A shared instance of `AppStorePaymentManager` static let shared = AppStorePaymentManager(queue: SKPaymentQueue.default()) + private let operationQueue = OperationQueue() private let queue: SKPaymentQueue private let rpc = MullvadRpc.withEphemeralURLSession() - private var paymentQueueSubscriber: AnyCancellable? - private var sendReceiptSubscriber: AnyCancellable? - - private var observers = [WeakAnyAppStorePaymentObserver]() + private var observerList = ObserverList<AnyAppStorePaymentObserver>() private let lock = NSRecursiveLock() private weak var classDelegate: AppStorePaymentManagerDelegate? @@ -133,45 +152,25 @@ class AppStorePaymentManager { } func startPaymentQueueMonitoring() { - paymentQueueSubscriber = queue.publisher.sink { [weak self] (transaction) in - self?.handleTransaction(transaction) - } + queue.add(self) } - // MARK: - Payment observation - - func addPaymentObserver(_ observer: AppStorePaymentObserver) { - lock.withCriticalBlock { - let isAlreadyObserving = self.observers.contains(where: { $0.inner === observer }) + // MARK: - SKPaymentTransactionObserver - if !isAlreadyObserving { - self.observers.append(WeakAnyAppStorePaymentObserver(observer)) - self.compactObservers() - } + func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) { + for transaction in transactions { + self.handleTransaction(transaction) } } - func removePaymentObserver(_ observer: AppStorePaymentObserver) { - lock.withCriticalBlock { - let index = self.observers.firstIndex(where: { $0.inner === observer }) - if let index = index { - self.observers.remove(at: index) - } - } - } + // MARK: - Payment observation - private func compactObservers() { - lock.withCriticalBlock { - observers.removeAll(where: { $0.inner == nil }) - } + func addPaymentObserver<T: AppStorePaymentObserver>(_ observer: T) { + self.observerList.append(AnyAppStorePaymentObserver(observer)) } - private func enumerateObservers(_ body: (AppStorePaymentObserver) -> Void) { - lock.withCriticalBlock { - observers.forEach { (observer) in - body(observer) - } - } + func removePaymentObserver<T: AppStorePaymentObserver>(_ observer: T) { + observerList.remove(AnyAppStorePaymentObserver(observer)) } // MARK: - Account token and payment mapping @@ -196,46 +195,75 @@ class AppStorePaymentManager { // MARK: - Products and payments - func requestProducts(with productIdentifiers: Set<AppStoreSubscription>) - -> SKRequestPublisher<SKProductsRequestSubscription> + func requestProducts(with productIdentifiers: Set<AppStoreSubscription>, + completionHandler: @escaping (Result<SKProductsResponse, Swift.Error>) -> Void) { let productIdentifiers = productIdentifiers.productIdentifiersSet - return SKProductsRequest(productIdentifiers: productIdentifiers).publisher + 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) -> AppStorePaymentPublisher { + func addPayment(_ payment: SKPayment, for accountToken: String) { associateAccountToken(accountToken, and: payment) - - return AppStorePaymentPublisher(paymentManager: self, queue: queue, payment: payment) + queue.add(payment) } - func restorePurchases(for accountToken: String) -> AnyPublisher<SendAppStoreReceiptResponse, AppStorePaymentManager.Error> { - return sendAppStoreReceipt(accountToken: accountToken, forceRefresh: true) + func restorePurchases( + for accountToken: String, + completionHandler: @escaping (Result<SendAppStoreReceiptResponse, AppStorePaymentManager.Error>) -> Void) { + return sendAppStoreReceipt( + accountToken: accountToken, + forceRefresh: true, + completionHandler: completionHandler + ) } // MARK: - Private methods - private func sendAppStoreReceipt(accountToken: String, forceRefresh: Bool) -> - AnyPublisher<SendAppStoreReceiptResponse, AppStorePaymentManager.Error> + private func sendAppStoreReceipt(accountToken: String, forceRefresh: Bool, completionHandler: @escaping (Result<SendAppStoreReceiptResponse, Error>) -> Void) { - return AppStoreReceipt.fetch(forceRefresh: forceRefresh) - .mapError { SendAppStoreReceiptError.read($0) } - .flatMap { (receiptData) in - self.rpc.sendAppStoreReceipt( + AppStoreReceipt.fetch(forceRefresh: forceRefresh) { (result) in + switch result { + case .success(let receiptData): + let request = self.rpc.sendAppStoreReceipt( accountToken: accountToken, receiptData: receiptData - ).mapError { SendAppStoreReceiptError.rpc($0) } + ) + + request.start { (result) in + switch result { + case .success(let response): + os_log( + .info, + "AppStore Receipt was processed. Time added: %{public}.2f, New expiry: %{private}s", + response.timeAdded, "\(response.newExpiry)") + + completionHandler(.success(response)) + + case .failure(let error): + completionHandler(.failure(.sendReceipt(error))) + } + } + + case .failure(let error): + completionHandler(.failure(.readReceipt(error))) + } } - .receive(on: DispatchQueue.main) - .handleEvents(receiveOutput: { (response) in - os_log( - .info, - "AppStore Receipt was processed. Time added: %{public}.2f, New expiry: %{private}s", - response.timeAdded, "\(response.newExpiry)") - }) - .mapError { AppStorePaymentManager.Error.sendReceipt($0) } - .eraseToAnyPublisher() } private func handleTransaction(_ transaction: SKPaymentTransaction) { @@ -272,97 +300,101 @@ class AppStorePaymentManager { private func didFailPurchase(transaction: SKPaymentTransaction) { queue.finishTransaction(transaction) - enumerateObservers { (observer) in + 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!)) } - _ = deassociateAccountToken(transaction.payment) } private func didFinishOrRestorePurchase(transaction: SKPaymentTransaction) { - let accountToken = deassociateAccountToken(transaction.payment) - - sendReceiptSubscriber = Just(accountToken) - .setFailureType(to: AppStorePaymentManager.Error.self) - .replaceNil(with: .noAccountSet) - .flatMap({ (accountToken) in - self.sendAppStoreReceipt(accountToken: accountToken, forceRefresh: false) - .retry(1) - }) - .receive(on: DispatchQueue.main) - .sink(receiveCompletion: { [weak self] (completion) in - guard let self = self else { return } + guard let accountToken = deassociateAccountToken(transaction.payment) else { + observerList.forEach { (observer) in + observer.appStorePaymentManager( + self, + transaction: transaction, + accountToken: nil, + didFailWithError: .noAccountSet) + } + return + } - switch completion { - case .finished: + 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): - os_log(.error, "Failed to upload the AppStore receipt: %{public}s", - error.localizedDescription) + error.logChain(message: "Failed to upload the AppStore receipt") - self.enumerateObservers { (observer) in + self.observerList.forEach { (observer) in observer.appStorePaymentManager( self, transaction: transaction, + accountToken: accountToken, didFailWithError: error) } } - }, receiveValue: { [weak self] (response) in - guard let self = self else { return } - - self.enumerateObservers { (observer) in - observer.appStorePaymentManager( - self, - transaction: transaction, - didFinishWithResponse: response) - } - }) + } + } } } +private class ProductsRequestOperation: AsyncOperation, OutputOperation, SKProductsRequestDelegate { + typealias Output = Result<SKProductsResponse, Error> -extension AppStorePaymentManager.Error: LocalizedError { + private let request: SKProductsRequest - var errorDescription: String? { - switch self { - case .noAccountSet: - return nil - case .storePayment: - return NSLocalizedString("AppStore payment", comment: "") - case .sendReceipt: - return NSLocalizedString("Communication error", comment: "") - } + init(request: SKProductsRequest) { + self.request = request + super.init() + + request.delegate = self } - var failureReason: String? { - switch self { - case .storePayment(let storeError): - return storeError.localizedDescription - case .sendReceipt(.rpc(.network(let urlError))): - return urlError.localizedDescription - case .sendReceipt(.rpc(.server(let serverError))): - return serverError.errorDescription - case .sendReceipt(.read(.refresh(let storeError))): - return storeError.localizedDescription - default: - return NSLocalizedString("Internal error", comment: "") - } + override func main() { + request.start() } - var recoverySuggestion: String? { - switch self { - case .noAccountSet: - return nil - case .storePayment: - return nil - case .sendReceipt: - return NSLocalizedString( - #"Please retry by using the "Restore purchases" button"#, comment: "") - } + 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/AppStorePaymentPublisher.swift b/ios/MullvadVPN/AppStorePaymentPublisher.swift deleted file mode 100644 index 31e6b9c878..0000000000 --- a/ios/MullvadVPN/AppStorePaymentPublisher.swift +++ /dev/null @@ -1,87 +0,0 @@ -// -// AppStorePaymentPublisher.swift -// MullvadVPN -// -// Created by pronebird on 23/03/2020. -// Copyright © 2020 Mullvad VPN AB. All rights reserved. -// - -import Combine -import Foundation -import StoreKit - -class AppStorePaymentPublisher: Publisher { - typealias Output = SendAppStoreReceiptResponse - typealias Failure = AppStorePaymentManager.Error - - private let paymentManager: AppStorePaymentManager - private let payment: SKPayment - private let queue: SKPaymentQueue - - init(paymentManager: AppStorePaymentManager, queue: SKPaymentQueue, payment: SKPayment) { - self.paymentManager = paymentManager - self.payment = payment - self.queue = queue - } - - func receive<S>(subscriber: S) where S : Subscriber, Failure == S.Failure, Output == S.Input { - let subscription = AppStorePaymentSubscription( - paymentManager: paymentManager, - queue: queue, - payment: payment, - subscriber: subscriber) - - subscriber.receive(subscription: subscription) - } -} - -private class AppStorePaymentSubscription: Subscription, AppStorePaymentObserver { - - typealias Output = SendAppStoreReceiptResponse - typealias Failure = AppStorePaymentManager.Error - - private let paymentManager: AppStorePaymentManager - private let payment: SKPayment - private let queue: SKPaymentQueue - private let subscriber: AnySubscriber<Output, Failure> - - init<S>(paymentManager: AppStorePaymentManager, queue: SKPaymentQueue, payment: SKPayment, subscriber: S) - where S: Subscriber, S.Input == Output, S.Failure == Failure - { - self.paymentManager = paymentManager - self.payment = payment - self.queue = queue - self.subscriber = AnySubscriber(subscriber) - - paymentManager.addPaymentObserver(self) - } - - func request(_ demand: Subscribers.Demand) { - queue.add(payment) - } - - func cancel() { - paymentManager.removePaymentObserver(self) - } - - // MARK: - AppStorePaymentObserver - - func appStorePaymentManager(_ manager: AppStorePaymentManager, - transaction: SKPaymentTransaction, - didFinishWithResponse response: SendAppStoreReceiptResponse) - { - if transaction.payment == payment { - _ = subscriber.receive(response) - subscriber.receive(completion: .finished) - } - } - - func appStorePaymentManager(_ manager: AppStorePaymentManager, - transaction: SKPaymentTransaction, - didFailWithError error: AppStorePaymentManager.Error) { - if transaction.payment == payment { - subscriber.receive(completion: .failure(error)) - } - } - -} diff --git a/ios/MullvadVPN/AppStoreReceipt.swift b/ios/MullvadVPN/AppStoreReceipt.swift index ecb2e8d130..f6e11ef49d 100644 --- a/ios/MullvadVPN/AppStoreReceipt.swift +++ b/ios/MullvadVPN/AppStoreReceipt.swift @@ -6,12 +6,11 @@ // Copyright © 2020 Mullvad VPN AB. All rights reserved. // -import Combine import Foundation import StoreKit enum AppStoreReceipt { - enum Error: Swift.Error { + enum Error: ChainedError { /// AppStore receipt file does not exist or file URL is not available case doesNotExist @@ -21,18 +20,21 @@ enum AppStoreReceipt { /// Failure to refresh the receipt from AppStore case refresh(Swift.Error) - var localizedDescription: String { + var errorDescription: String? { switch self { case .doesNotExist: return "AppStore receipt file does not exist on disk" - case .io(let ioError): - return "Read error: \(ioError.localizedDescription)" - case .refresh(let refreshError): - return "Receipt refresh error: \(refreshError.localizedDescription)" + case .io: + return "Read error" + case .refresh: + return "Receipt refresh error" } } } + /// An operation queue used to run receipt refresh requests + private static let operationQueue = OperationQueue() + /// Read AppStore receipt from disk static func readFromDisk() -> Result<Data, Error> { guard let appStoreReceiptURL = Bundle.main.appStoreReceiptURL else { @@ -51,29 +53,68 @@ enum AppStoreReceipt { /// Read AppStore receipt from disk or refresh it from the 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) -> AnyPublisher<Data, Error> { - let refreshReceiptPublisher = Deferred { - SKReceiptRefreshRequest(receiptProperties: receiptProperties) - .publisher - .mapError { .refresh($0) } - .flatMap({ _ -> Result<Data, Error>.Publisher in - return self.readFromDisk().publisher - }) - } - + static func fetch(forceRefresh: Bool = false, receiptProperties: [String: Any]? = nil, + completionHandler: @escaping (Result<Data, Error>) -> Void) + { if forceRefresh { - return refreshReceiptPublisher.eraseToAnyPublisher() + refreshReceipt(receiptProperties: receiptProperties, + completionHandler: completionHandler) } else { - return Deferred { self.readFromDisk().publisher } - .catch({ (readError) -> AnyPublisher<Data, Error> in - // Refresh the receipt from AppStore if it's not on disk - if case .doesNotExist = readError { - return refreshReceiptPublisher.eraseToAnyPublisher() - } else { - return Fail(error: readError).eraseToAnyPublisher() - } - }) - .eraseToAnyPublisher() + switch self.readFromDisk() { + case .success(let data): + completionHandler(.success(data)) + + case .failure(let error): + // Refresh the receipt from AppStore if it's not on disk + if case .doesNotExist = error { + refreshReceipt(receiptProperties: receiptProperties, + completionHandler: completionHandler) + } else { + completionHandler(.failure(error)) + } + } } } + + private static func refreshReceipt(receiptProperties: [String: Any]?, completionHandler: @escaping (Result<Data, Error>) -> Void) { + let refreshOperation = ReceiptRefreshOperation(receiptProperties: receiptProperties) + refreshOperation.addDidFinishBlockObserver { (operation, result) in + let result = result + .mapError { Error.refresh($0) } + .flatMap { Self.readFromDisk() } + completionHandler(result) + } + + operationQueue.addOperation(refreshOperation) + } +} + + +private class ReceiptRefreshOperation: AsyncOperation, OutputOperation, SKRequestDelegate { + typealias Output = Result<(), Error> + + private let request: SKReceiptRefreshRequest + + init(receiptProperties: [String: Any]?) { + request = SKReceiptRefreshRequest(receiptProperties: receiptProperties) + } + + override func main() { + request.delegate = self + request.start() + } + + override func operationDidCancel() { + request.cancel() + } + + // - MARK: SKRequestDelegate + + func requestDidFinish(_ request: SKRequest) { + finish(with: .success(())) + } + + func request(_ request: SKRequest, didFailWithError error: Error) { + finish(with: .failure(error)) + } } diff --git a/ios/MullvadVPN/DisplayChainedError.swift b/ios/MullvadVPN/DisplayChainedError.swift index 6015dc2de7..ff6bd69528 100644 --- a/ios/MullvadVPN/DisplayChainedError.swift +++ b/ios/MullvadVPN/DisplayChainedError.swift @@ -123,3 +123,39 @@ extension Account.Error: DisplayChainedError { } } + +extension AppStorePaymentManager.Error: DisplayChainedError { + var errorChainDescription: String? { + switch self { + case .noAccountSet: + return NSLocalizedString("Internal error: account is not set", comment: "") + + case .readReceipt(let readReceiptError): + return String(format: NSLocalizedString("Cannot read the receipt: %@", comment: ""), readReceiptError.errorChainDescription ?? "") + + case .sendReceipt(let rpcError): + let reason = rpcError.errorChainDescription ?? "" + + return String(format: NSLocalizedString(#"Failed to send the receipt to server: %@\n\nPlease retry by using the "Restore purchases" button."#, comment: ""), reason) + + case .storePayment(let storeError): + return storeError.localizedDescription + } + } +} + +extension AppStoreReceipt.Error: DisplayChainedError { + var errorChainDescription: String? { + switch self { + case .doesNotExist: + return NSLocalizedString("AppStore receipt does not exist", comment: "") + + case .io(let readError): + return String(format: NSLocalizedString("Read error: %@", comment: ""), + readError.localizedDescription) + + case .refresh(let refreshError): + return String(format: NSLocalizedString("Failed to refresh the receipt: %@", comment: ""), refreshError.localizedDescription) + } + } +} diff --git a/ios/MullvadVPN/SKPaymentQueuePublisher.swift b/ios/MullvadVPN/SKPaymentQueuePublisher.swift deleted file mode 100644 index 04c1cf64ee..0000000000 --- a/ios/MullvadVPN/SKPaymentQueuePublisher.swift +++ /dev/null @@ -1,71 +0,0 @@ -// -// SKPaymentQueuePublisher.swift -// MullvadVPN -// -// Created by pronebird on 17/03/2020. -// Copyright © 2020 Mullvad VPN AB. All rights reserved. -// - -import Combine -import Foundation -import StoreKit - -/// A publisher that indefinitely emits the incoming transactions on the given `SKPaymentQueue`, -/// and never completes. -struct SKPaymentQueuePublisher: Publisher { - typealias Output = SKPaymentTransaction - typealias Failure = Never - - private let queue: SKPaymentQueue - - init(queue: SKPaymentQueue) { - self.queue = queue - } - - func receive<S>(subscriber: S) where S : Subscriber, Failure == S.Failure, Output == S.Input { - let subscription = SKPaymentQueueSubscription( - queue: queue, subscriber: subscriber) - subscriber.receive(subscription: subscription) - } - -} - -extension SKPaymentQueue { - var publisher: SKPaymentQueuePublisher { - return .init(queue: self) - } -} - -/// A subscription implementation for the given `SKPaymentQueue` -private class SKPaymentQueueSubscription: NSObject, Subscription, SKPaymentTransactionObserver { - private let queue: SKPaymentQueue - private let subscriber: AnySubscriber<SKPaymentTransaction, Never> - - init<S>(queue: SKPaymentQueue, subscriber: S) - where S: Subscriber, S.Failure == Never, S.Input == SKPaymentTransaction - { - self.queue = queue - self.subscriber = AnySubscriber(subscriber) - - super.init() - - queue.add(self) - } - - func request(_ demand: Subscribers.Demand) { - // no-op - } - - func cancel() { - queue.remove(self) - } - - // MARK: - SKPaymentTransactionObserver - - func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) { - for transaction in transactions { - _ = subscriber.receive(transaction) - } - } - -} diff --git a/ios/MullvadVPN/SKRequestPublisher.swift b/ios/MullvadVPN/SKRequestPublisher.swift deleted file mode 100644 index 1e2786eb6e..0000000000 --- a/ios/MullvadVPN/SKRequestPublisher.swift +++ /dev/null @@ -1,121 +0,0 @@ -// -// SKRequestPublisher.swift -// MullvadVPN -// -// Created by pronebird on 11/03/2020. -// Copyright © 2020 Mullvad VPN AB. All rights reserved. -// - -import Combine -import Foundation -import StoreKit - -/// A protocol that formalizes the interface of all of the subclasses of -/// `SKRequestSubscription<Output>` -protocol SKRequestSubscriptionProtocol: Subscription { - associatedtype Output - associatedtype Failure: Error - - init<S: Subscriber>(request: SKRequest, subscriber: S) - where S.Input == Output, S.Failure == Failure -} - -/// A base implementation of subscription that handles the `SKRequest`. -class SKRequestSubscription<Output>: NSObject, Subscription, SKRequestDelegate, - SKRequestSubscriptionProtocol -{ - typealias Failure = Error - - private let request: SKRequest - fileprivate let subscriber: AnySubscriber<Output, Failure> - - required init<S: Subscriber>(request: SKRequest, subscriber: S) - where S.Input == Output, S.Failure == Failure - { - self.request = request - self.subscriber = AnySubscriber(subscriber) - - super.init() - request.delegate = self - } - - func request(_ demand: Subscribers.Demand) { - request.start() - } - - func cancel() { - request.cancel() - } - - // MARK: - SKRequestDelegate - - func request(_ request: SKRequest, didFailWithError error: Error) { - subscriber.receive(completion: .failure(error)) - } - - func requestDidFinish(_ request: SKRequest) { - subscriber.receive(completion: .finished) - } -} - -/// A subscription that emits the `SKProductsResponse` upon request completion -class SKProductsRequestSubscription: SKRequestSubscription<SKProductsResponse>, - SKProductsRequestDelegate -{ - - // MARK: - SKProductsRequestDelegate - - func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) { - _ = self.subscriber.receive(response) - } - -} - -/// A subscription for requesting the AppStore receipt refresh -class SKRefreshRequestSubscription: SKRequestSubscription<()> { - override func requestDidFinish(_ request: SKRequest) { - // Emit void so that publishers using this subscription could be chained - _ = self.subscriber.receive(()) - - super.requestDidFinish(request) - } -} - -/// A base implementation of publisher that runs `SKRequest`s -class SKRequestPublisher<SubscriptionType>: Publisher - where SubscriptionType: SKRequestSubscriptionProtocol -{ - typealias Output = SubscriptionType.Output - typealias Failure = SubscriptionType.Failure - - fileprivate let request: SKRequest - - init(request: SKRequest) { - self.request = request - } - - func receive<S>(subscriber: S) where S : Subscriber, Failure == S.Failure, Output == S.Input { - let subscription = SubscriptionType(request: request, subscriber: subscriber) - - subscriber.receive(subscription: subscription) - } - -} - -protocol SKRequestPublishing { - associatedtype SubscriptionType: SKRequestSubscriptionProtocol - - var publisher: SKRequestPublisher<SubscriptionType> { get } -} - -extension SKProductsRequest: SKRequestPublishing { - var publisher: SKRequestPublisher<SKProductsRequestSubscription> { - return .init(request: self) - } -} - -extension SKReceiptRefreshRequest: SKRequestPublishing { - var publisher: SKRequestPublisher<SKRefreshRequestSubscription> { - return .init(request: self) - } -} diff --git a/ios/MullvadVPN/UIAlertController+Error.swift b/ios/MullvadVPN/UIAlertController+Error.swift deleted file mode 100644 index 3552b487ec..0000000000 --- a/ios/MullvadVPN/UIAlertController+Error.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// UIAlertController+Error.swift -// MullvadVPN -// -// Created by pronebird on 11/12/2019. -// Copyright © 2019 Mullvad VPN AB. All rights reserved. -// - -import Foundation -import UIKit - -/// An extension for presenting `LocalizedError` subclasses in `UIAlertController` -extension UIAlertController { - - convenience init<Error>(_ error: Error, preferredStyle: UIAlertController.Style) - where Error: LocalizedError - { - let title = error.errorDescription - let message = [error.failureReason, error.recoverySuggestion] - .compactMap { $0 } - .joined(separator: "\n\n") - - self.init(title: title, message: message, preferredStyle: preferredStyle) - } - -} - -extension UIViewController { - - /// Present an instance of `LocalizedError` using `UIAlertController` - /// Note: this method adds a default "OK" action when `configurationBlock` is not given - func presentError<Error>( - _ error: Error, - preferredStyle: UIAlertController.Style, - configurationBlock: ((UIAlertController) -> Void)? = nil, - completionBlock: (() -> Void)? = nil) - where Error: LocalizedError - { - let alertController = UIAlertController(error, preferredStyle: preferredStyle) - - if let configurationBlock = configurationBlock { - configurationBlock(alertController) - } else { - alertController.addAction( - UIAlertAction(title: NSLocalizedString("OK", comment: ""), style: .cancel) - ) - } - - self.present(alertController, animated: true, completion: completionBlock) - } - -} diff --git a/ios/MullvadVPN/UserInterfaceInteractionRestriction.swift b/ios/MullvadVPN/UserInterfaceInteractionRestriction.swift index a8f1455b5f..865dc29366 100644 --- a/ios/MullvadVPN/UserInterfaceInteractionRestriction.swift +++ b/ios/MullvadVPN/UserInterfaceInteractionRestriction.swift @@ -6,35 +6,31 @@ // Copyright © 2020 Mullvad VPN AB. All rights reserved. // -import Combine import Foundation /// A protocol describing a common interface for the implementations of user interaction restriction protocol UserInterfaceInteractionRestrictionProtocol { - /// Raise the user interface interaction restrictions - func lift(animated: Bool) + /// Increase the user interface interaction restrictions + func increase(animated: Bool) - /// Lift the user interface interaction restrictions - func raise(animated: Bool) + /// Decrease the user interface interaction restrictions + func decrease(animated: Bool) } /// A counter based user interface interaction restriction implementation -class UserInterfaceInteractionRestriction<S: Scheduler> - : UserInterfaceInteractionRestrictionProtocol +class UserInterfaceInteractionRestriction: UserInterfaceInteractionRestrictionProtocol { typealias Action = (_ disableUserInteraction: Bool, _ animated: Bool) -> Void private let action: Action - private let scheduler: S private var counter: UInt = 0 - init(scheduler: S, action: @escaping Action) { + init(action: @escaping Action) { self.action = action - self.scheduler = scheduler } - func raise(animated: Bool) { - scheduler.schedule { + func increase(animated: Bool) { + DispatchQueue.main.async { if self.counter == 0 { self.action(false, animated) } @@ -42,8 +38,8 @@ class UserInterfaceInteractionRestriction<S: Scheduler> } } - func lift(animated: Bool) { - scheduler.schedule { + func decrease(animated: Bool) { + DispatchQueue.main.async { guard self.counter > 0 else { return } self.counter -= 1 @@ -64,27 +60,11 @@ class CompoundUserInterfaceInteractionRestriction: UserInterfaceInteractionRestr self.restrictions = restrictions } - func lift(animated: Bool) { - restrictions.forEach { $0.lift(animated: animated) } + func decrease(animated: Bool) { + restrictions.forEach { $0.decrease(animated: animated) } } - func raise(animated: Bool) { - restrictions.forEach { $0.raise(animated: animated) } - } -} - -extension Publisher { - func restrictUserInterfaceInteraction( - with restriction: UserInterfaceInteractionRestrictionProtocol, - animated: Bool - ) -> Publishers.HandleEvents<Self> - { - return handleEvents(receiveSubscription: { _ in - restriction.raise(animated: animated) - }, receiveCompletion: { _ in - restriction.lift(animated: animated) - }, receiveCancel: { () in - restriction.lift(animated: animated) - }) + func increase(animated: Bool) { + restrictions.forEach { $0.increase(animated: animated) } } } |
