summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--ios/MullvadVPN.xcodeproj/project.pbxproj4
-rw-r--r--ios/MullvadVPN/AccountViewController.swift344
-rw-r--r--ios/MullvadVPN/UserInterfaceInteractionRestriction.swift69
3 files changed, 182 insertions, 235 deletions
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj
index 2ad363563b..92beeb5814 100644
--- a/ios/MullvadVPN.xcodeproj/project.pbxproj
+++ b/ios/MullvadVPN.xcodeproj/project.pbxproj
@@ -302,7 +302,6 @@
58FC040A27B3EE03001C21F0 /* TunnelMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FC040927B3EE03001C21F0 /* TunnelMonitor.swift */; };
58FD5BE724192A2C00112C88 /* AppStoreReceipt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FD5BE624192A2B00112C88 /* AppStoreReceipt.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 */; };
58FEAFB92750DA2F003C1625 /* AddressCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FEAFB82750DA2F003C1625 /* AddressCache.swift */; };
58FEEB46260A028D00A621A8 /* GeoJSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FEEB45260A028D00A621A8 /* GeoJSON.swift */; };
@@ -590,7 +589,6 @@
58FC040927B3EE03001C21F0 /* TunnelMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelMonitor.swift; sourceTree = "<group>"; };
58FD5BE624192A2B00112C88 /* AppStoreReceipt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreReceipt.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>"; };
58FEAFB82750DA2F003C1625 /* AddressCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressCache.swift; sourceTree = "<group>"; };
58FEEB45260A028D00A621A8 /* GeoJSON.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeoJSON.swift; sourceTree = "<group>"; };
@@ -972,7 +970,6 @@
58289081286B590900478596 /* UIFont+Monospaced.swift */,
5856D13627450A8A00DFD627 /* UIImage+TintColor.swift */,
585CA70E25F8C44600B47C62 /* UIMetrics.swift */,
- 58FD5BF12424F7D700112C88 /* UserInterfaceInteractionRestriction.swift */,
58F7CA872692E34000FC59FD /* WireguardKeysContentView.swift */,
);
path = MullvadVPN;
@@ -1491,7 +1488,6 @@
58B43C1925F77DB60002C8C3 /* ConnectContentView.swift in Sources */,
58561C99239A5D1500BD6B5E /* IPEndpoint.swift in Sources */,
58F97A1E280FDE230050C2FC /* RESTRequestHandler.swift in Sources */,
- 58FD5BF22424F7D700112C88 /* UserInterfaceInteractionRestriction.swift in Sources */,
5811DE50239014550011EB53 /* NEVPNStatus+Debug.swift in Sources */,
58C3A4B222456F1B00340BDB /* AccountInputGroupView.swift in Sources */,
58059DDE28468158002B1049 /* OutputOperation.swift in Sources */,
diff --git a/ios/MullvadVPN/AccountViewController.swift b/ios/MullvadVPN/AccountViewController.swift
index a755ef2239..6bd0a50151 100644
--- a/ios/MullvadVPN/AccountViewController.swift
+++ b/ios/MullvadVPN/AccountViewController.swift
@@ -15,39 +15,19 @@ protocol AccountViewControllerDelegate: AnyObject {
}
class AccountViewController: UIViewController, AppStorePaymentObserver, TunnelObserver {
+ private let alertPresenter = AlertPresenter()
+
private let contentView: AccountContentView = {
let contentView = AccountContentView()
contentView.translatesAutoresizingMaskIntoConstraints = false
return contentView
}()
- private var copyToPasteboardWork: DispatchWorkItem?
-
- private var pendingPayment: SKPayment?
- private let alertPresenter = AlertPresenter()
+ private var productState: ProductState = .none
+ private var paymentState: PaymentState = .none
weak var delegate: AccountViewControllerDelegate?
- private lazy var purchaseButtonInteractionRestriction =
- UserInterfaceInteractionRestriction { [weak self] enableUserInteraction, _ in
- // Make sure to disable the button if the product is not loaded
- self?.contentView.purchaseButton.isEnabled = enableUserInteraction &&
- self?.product != nil &&
- AppStorePaymentManager.canMakePayments
- }
-
- private lazy var viewControllerInteractionRestriction =
- UserInterfaceInteractionRestriction { [weak self] enableUserInteraction, animated in
- self?.setEnableUserInteraction(enableUserInteraction, animated: true)
- }
-
- private lazy var compoundInteractionRestriction =
- CompoundUserInterfaceInteractionRestriction(restrictions: [
- purchaseButtonInteractionRestriction, viewControllerInteractionRestriction,
- ])
-
- private var product: SKProduct?
-
// MARK: - View lifecycle
override var preferredStatusBarStyle: UIStatusBarStyle {
@@ -105,17 +85,43 @@ class AccountViewController: UIViewController, AppStorePaymentObserver, TunnelOb
TunnelManager.shared.addObserver(self)
updateView(from: TunnelManager.shared.deviceState)
+ applyViewState(animated: false)
- // Make sure to disable IAPs when payments are restricted
if AppStorePaymentManager.canMakePayments {
requestStoreProducts()
} else {
- setPaymentsRestricted()
+ setProductState(.cannotMakePurchases, animated: false)
}
}
// MARK: - Private methods
+ private func requestStoreProducts() {
+ let productKind = AppStoreSubscription.thirtyDays
+
+ setProductState(.fetching(productKind), animated: true)
+
+ _ = AppStorePaymentManager.shared
+ .requestProducts(with: [productKind]) { [weak self] completion in
+ let productState: ProductState = completion.value?.products.first
+ .map { .received($0) } ?? .failed
+
+ self?.setProductState(productState, animated: true)
+ }
+ }
+
+ private func setPaymentState(_ newState: PaymentState, animated: Bool) {
+ paymentState = newState
+
+ applyViewState(animated: animated)
+ }
+
+ private func setProductState(_ newState: ProductState, animated: Bool) {
+ productState = newState
+
+ applyViewState(animated: animated)
+ }
+
private func updateView(from deviceState: DeviceState?) {
guard case let .loggedIn(accountData, deviceData) = deviceState else {
return
@@ -126,101 +132,84 @@ class AccountViewController: UIViewController, AppStorePaymentObserver, TunnelOb
contentView.accountExpiryRowView.value = accountData.expiry
}
- private func requestStoreProducts() {
- let inAppPurchase = AppStoreSubscription.thirtyDays
-
- contentView.purchaseButton.setTitle(inAppPurchase.localizedTitle, for: .normal)
- contentView.purchaseButton.isLoading = true
-
- purchaseButtonInteractionRestriction.increase(animated: true)
-
- _ = AppStorePaymentManager.shared
- .requestProducts(with: [inAppPurchase]) { [weak self] completion in
- guard let self = self else { return }
+ private func applyViewState(animated: Bool) {
+ let isInteractionEnabled = paymentState.allowsViewInteraction
+ let purchaseButton = contentView.purchaseButton
+ let activityIndicator = contentView.accountExpiryRowView.activityIndicator
- switch completion {
- case let .success(response):
- if let product = response.products.first {
- self.setProduct(product, animated: true)
- }
+ if productState.isFetching || paymentState != .none {
+ activityIndicator.startAnimating()
+ } else {
+ activityIndicator.stopAnimating()
+ }
- case let .failure(error):
- self.didFailLoadingProducts(with: error)
+ purchaseButton.setTitle(productState.purchaseButtonTitle, for: .normal)
+ contentView.purchaseButton.isLoading = productState.isFetching
- case .cancelled:
- break
- }
+ purchaseButton.isEnabled = productState.isReceived && isInteractionEnabled
+ contentView.restorePurchasesButton.isEnabled = isInteractionEnabled
+ contentView.logoutButton.isEnabled = isInteractionEnabled
- self.contentView.purchaseButton.isLoading = false
- self.purchaseButtonInteractionRestriction.decrease(animated: true)
- }
+ view.isUserInteractionEnabled = isInteractionEnabled
+ if #available(iOS 13.0, *) {
+ isModalInPresentation = !isInteractionEnabled
+ }
+ navigationItem.setHidesBackButton(!isInteractionEnabled, animated: animated)
}
- private func setProduct(_ product: SKProduct, animated: Bool) {
- self.product = product
-
- let localizedTitle = product.customLocalizedTitle ?? ""
- let localizedPrice = product.localizedPrice ?? ""
-
- let format = NSLocalizedString(
- "PURCHASE_BUTTON_TITLE_FORMAT",
- tableName: "Account",
- value: "%1$@ (%2$@)",
- comment: ""
- )
- let title = String(format: format, localizedTitle, localizedPrice)
+ private func didProcessPayment(_ payment: SKPayment) {
+ guard case let .makingPayment(pendingPayment) = paymentState,
+ pendingPayment == payment else { return }
- contentView.purchaseButton.setTitle(title, for: .normal)
+ setPaymentState(.none, animated: true)
}
- private func didFailLoadingProducts(with error: Error) {
- let title = NSLocalizedString(
- "PURCHASE_BUTTON_CANNOT_CONNECT_TO_APPSTORE_LABEL",
- tableName: "Account",
- value: "Cannot connect to AppStore",
- comment: ""
+ private func showPaymentErrorAlert(error: AppStorePaymentManager.Error) {
+ let alertController = UIAlertController(
+ title: NSLocalizedString(
+ "CANNOT_COMPLETE_PURCHASE_ALERT_TITLE",
+ tableName: "Account",
+ value: "Cannot complete the purchase",
+ comment: ""
+ ),
+ message: error.errorChainDescription,
+ preferredStyle: .alert
)
- contentView.purchaseButton.setTitle(title, for: .normal)
- }
-
- private func setPaymentsRestricted() {
- let title = NSLocalizedString(
- "PURCHASE_BUTTON_PAYMENTS_RESTRICTED_LABEL",
- tableName: "Account",
- value: "Payments restricted",
- comment: ""
+ alertController.addAction(
+ UIAlertAction(
+ title: NSLocalizedString(
+ "CANNOT_COMPLETE_PURCHASE_ALERT_OK_ACTION",
+ tableName: "Account",
+ value: "OK",
+ comment: ""
+ ), style: .cancel
+ )
)
- contentView.purchaseButton.setTitle(title, for: .normal)
- contentView.purchaseButton.isEnabled = false
+ alertPresenter.enqueue(alertController, presentingController: self)
}
- private func setEnableUserInteraction(_ enableUserInteraction: Bool, animated: Bool) {
- // Disable all buttons
- [contentView.restorePurchasesButton, contentView.logoutButton].forEach { button in
- button?.isEnabled = enableUserInteraction
- }
-
- // Disable any interaction within the view
- view.isUserInteractionEnabled = enableUserInteraction
-
- // Prevent view controller from being swiped away by user
- if #available(iOS 13.0, *) {
- isModalInPresentation = !enableUserInteraction
- } else {
- // Fallback on earlier versions
- }
-
- // Hide back button in navigation bar
- navigationItem.setHidesBackButton(!enableUserInteraction, animated: animated)
-
- // Show/hide the spinner next to "Paid until"
- if enableUserInteraction {
- contentView.accountExpiryRowView.activityIndicator.stopAnimating()
- } else {
- contentView.accountExpiryRowView.activityIndicator.startAnimating()
- }
+ private func showRestorePurchasesErrorAlert(error: AppStorePaymentManager.Error) {
+ let alertController = UIAlertController(
+ title: NSLocalizedString(
+ "RESTORE_PURCHASES_FAILURE_ALERT_TITLE",
+ tableName: "Account",
+ value: "Cannot restore purchases",
+ comment: ""
+ ),
+ message: error.errorChainDescription,
+ preferredStyle: .alert
+ )
+ alertController.addAction(
+ UIAlertAction(title: NSLocalizedString(
+ "RESTORE_PURCHASES_FAILURE_ALERT_OK_ACTION",
+ tableName: "Account",
+ value: "OK",
+ comment: ""
+ ), style: .cancel)
+ )
+ alertPresenter.enqueue(alertController, presentingController: self)
}
private func showTimeAddedConfirmationAlert(
@@ -356,33 +345,9 @@ class AccountViewController: UIViewController, AppStorePaymentObserver, TunnelOb
accountToken: String?,
didFailWithError error: AppStorePaymentManager.Error
) {
- let alertController = UIAlertController(
- title: NSLocalizedString(
- "CANNOT_COMPLETE_PURCHASE_ALERT_TITLE",
- tableName: "Account",
- value: "Cannot complete the purchase",
- comment: ""
- ),
- message: error.errorChainDescription,
- preferredStyle: .alert
- )
+ showPaymentErrorAlert(error: error)
- alertController.addAction(
- UIAlertAction(
- title: NSLocalizedString(
- "CANNOT_COMPLETE_PURCHASE_ALERT_OK_ACTION",
- tableName: "Account",
- value: "OK",
- comment: ""
- ), style: .cancel
- )
- )
-
- alertPresenter.enqueue(alertController, presentingController: self)
-
- if payment == pendingPayment {
- compoundInteractionRestriction.decrease(animated: true)
- }
+ didProcessPayment(payment)
}
func appStorePaymentManager(
@@ -393,9 +358,7 @@ class AccountViewController: UIViewController, AppStorePaymentObserver, TunnelOb
) {
showTimeAddedConfirmationAlert(with: response, context: .purchase)
- if transaction.payment == pendingPayment {
- compoundInteractionRestriction.decrease(animated: true)
- }
+ didProcessPayment(transaction.payment)
}
// MARK: - Actions
@@ -417,18 +380,16 @@ class AccountViewController: UIViewController, AppStorePaymentObserver, TunnelOb
}
@objc private func doPurchase() {
- guard let accountData = TunnelManager.shared.deviceState.accountData,
- let product = product
+ guard case let .received(product) = productState,
+ let accountData = TunnelManager.shared.deviceState.accountData
else {
return
}
let payment = SKPayment(product: product)
-
- pendingPayment = payment
- compoundInteractionRestriction.increase(animated: true)
-
AppStorePaymentManager.shared.addPayment(payment, for: accountData.number)
+
+ setPaymentState(.makingPayment(payment), animated: true)
}
@objc private func restorePurchases() {
@@ -436,7 +397,7 @@ class AccountViewController: UIViewController, AppStorePaymentObserver, TunnelOb
return
}
- compoundInteractionRestriction.increase(animated: true)
+ setPaymentState(.restoringPurchases, animated: true)
_ = AppStorePaymentManager.shared.restorePurchases(for: accountData.number) { completion in
switch completion {
@@ -444,31 +405,13 @@ class AccountViewController: UIViewController, AppStorePaymentObserver, TunnelOb
self.showTimeAddedConfirmationAlert(with: response, context: .restoration)
case let .failure(error):
- let alertController = UIAlertController(
- title: NSLocalizedString(
- "RESTORE_PURCHASES_FAILURE_ALERT_TITLE",
- tableName: "Account",
- value: "Cannot restore purchases",
- comment: ""
- ),
- message: error.errorChainDescription,
- preferredStyle: .alert
- )
- alertController.addAction(
- UIAlertAction(title: NSLocalizedString(
- "RESTORE_PURCHASES_FAILURE_ALERT_OK_ACTION",
- tableName: "Account",
- value: "OK",
- comment: ""
- ), style: .cancel)
- )
- self.alertPresenter.enqueue(alertController, presentingController: self)
+ self.showRestorePurchasesErrorAlert(error: error)
case .cancelled:
break
}
- self.compoundInteractionRestriction.decrease(animated: true)
+ self.setPaymentState(.none, animated: true)
}
}
}
@@ -533,3 +476,80 @@ private extension REST.CreateApplePaymentResponse {
}
}
}
+
+private extension AccountViewController {
+ enum PaymentState: Equatable {
+ case none
+ case makingPayment(SKPayment)
+ case restoringPurchases
+
+ var allowsViewInteraction: Bool {
+ switch self {
+ case .none:
+ return true
+ case .restoringPurchases, .makingPayment:
+ return false
+ }
+ }
+ }
+
+ enum ProductState {
+ case none
+ case fetching(AppStoreSubscription)
+ case received(SKProduct)
+ case failed
+ case cannotMakePurchases
+
+ var isFetching: Bool {
+ if case .fetching = self {
+ return true
+ }
+ return false
+ }
+
+ var isReceived: Bool {
+ if case .received = self {
+ return true
+ }
+ return false
+ }
+
+ var purchaseButtonTitle: String? {
+ switch self {
+ case .none:
+ return nil
+
+ case let .fetching(subscription):
+ return subscription.localizedTitle
+
+ case let .received(product):
+ let localizedTitle = product.customLocalizedTitle ?? ""
+ let localizedPrice = product.localizedPrice ?? ""
+
+ let format = NSLocalizedString(
+ "PURCHASE_BUTTON_TITLE_FORMAT",
+ tableName: "Account",
+ value: "%1$@ (%2$@)",
+ comment: ""
+ )
+ return String(format: format, localizedTitle, localizedPrice)
+
+ case .failed:
+ return NSLocalizedString(
+ "PURCHASE_BUTTON_CANNOT_CONNECT_TO_APPSTORE_LABEL",
+ tableName: "Account",
+ value: "Cannot connect to AppStore",
+ comment: ""
+ )
+
+ case .cannotMakePurchases:
+ return NSLocalizedString(
+ "PURCHASE_BUTTON_PAYMENTS_RESTRICTED_LABEL",
+ tableName: "Account",
+ value: "Payments restricted",
+ comment: ""
+ )
+ }
+ }
+ }
+}
diff --git a/ios/MullvadVPN/UserInterfaceInteractionRestriction.swift b/ios/MullvadVPN/UserInterfaceInteractionRestriction.swift
deleted file mode 100644
index ca5e63dfce..0000000000
--- a/ios/MullvadVPN/UserInterfaceInteractionRestriction.swift
+++ /dev/null
@@ -1,69 +0,0 @@
-//
-// UserInterfaceInteractionRestriction.swift
-// MullvadVPN
-//
-// Created by pronebird on 20/03/2020.
-// Copyright © 2020 Mullvad VPN AB. All rights reserved.
-//
-
-import Foundation
-
-/// A protocol describing a common interface for the implementations of user interaction restriction
-protocol UserInterfaceInteractionRestrictionProtocol {
- /// Increase the user interface interaction restrictions
- func increase(animated: Bool)
-
- /// Decrease the user interface interaction restrictions
- func decrease(animated: Bool)
-}
-
-/// A counter based user interface interaction restriction implementation
-class UserInterfaceInteractionRestriction: UserInterfaceInteractionRestrictionProtocol {
- typealias Action = (_ enableUserInteraction: Bool, _ animated: Bool) -> Void
-
- private let action: Action
- private var counter: UInt = 0
-
- init(action: @escaping Action) {
- self.action = action
- }
-
- func increase(animated: Bool) {
- DispatchQueue.main.async {
- if self.counter == 0 {
- self.action(false, animated)
- }
- self.counter += 1
- }
- }
-
- func decrease(animated: Bool) {
- DispatchQueue.main.async {
- guard self.counter > 0 else { return }
-
- self.counter -= 1
- if self.counter == 0 {
- self.action(true, animated)
- }
- }
- }
-}
-
-/// A user interface restriction implementation that simply combines multiple child restrictions
-/// into one and automatically forwards all calls to them in the order in which they are given to
-/// the initializer.
-class CompoundUserInterfaceInteractionRestriction: UserInterfaceInteractionRestrictionProtocol {
- private let restrictions: [UserInterfaceInteractionRestrictionProtocol]
-
- init(restrictions: [UserInterfaceInteractionRestrictionProtocol]) {
- self.restrictions = restrictions
- }
-
- func decrease(animated: Bool) {
- restrictions.forEach { $0.decrease(animated: animated) }
- }
-
- func increase(animated: Bool) {
- restrictions.forEach { $0.increase(animated: animated) }
- }
-}