summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--ios/MullvadVPN.xcodeproj/project.pbxproj18
-rw-r--r--ios/MullvadVPN/Account.swift24
-rw-r--r--ios/MullvadVPN/AccountViewController.swift202
-rw-r--r--ios/MullvadVPN/AppStorePaymentManager.swift302
-rw-r--r--ios/MullvadVPN/AppStorePaymentPublisher.swift87
-rw-r--r--ios/MullvadVPN/AppStoreReceipt.swift97
-rw-r--r--ios/MullvadVPN/DisplayChainedError.swift36
-rw-r--r--ios/MullvadVPN/SKPaymentQueuePublisher.swift71
-rw-r--r--ios/MullvadVPN/SKRequestPublisher.swift121
-rw-r--r--ios/MullvadVPN/UIAlertController+Error.swift52
-rw-r--r--ios/MullvadVPN/UserInterfaceInteractionRestriction.swift48
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) }
}
}