diff options
| author | Andrej Mihajlov <and@mullvad.net> | 2022-08-12 10:09:48 +0200 |
|---|---|---|
| committer | Andrej Mihajlov <and@mullvad.net> | 2022-08-12 14:57:05 +0200 |
| commit | 2d2aab3570a832f27afb211c37fee1e79e95edef (patch) | |
| tree | b8186da384b3305c310fc781578194ef4a8c9b31 | |
| parent | 71ec048de720ac3c12395d2270534e677dd0d3ce (diff) | |
| download | mullvadvpn-2d2aab3570a832f27afb211c37fee1e79e95edef.tar.xz mullvadvpn-2d2aab3570a832f27afb211c37fee1e79e95edef.zip | |
Remove ui interaction restriction
| -rw-r--r-- | ios/MullvadVPN.xcodeproj/project.pbxproj | 4 | ||||
| -rw-r--r-- | ios/MullvadVPN/AccountViewController.swift | 344 | ||||
| -rw-r--r-- | ios/MullvadVPN/UserInterfaceInteractionRestriction.swift | 69 |
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) } - } -} |
