diff options
| author | Andreas Lif <andreas.lif@shortcut.io> | 2022-08-04 11:29:23 +0200 |
|---|---|---|
| committer | Andreas Lif <andreas.lif@shortcut.io> | 2022-08-16 13:44:35 +0200 |
| commit | a8be84b07f8d04deca587b9a73133d9e0c802fe7 (patch) | |
| tree | d943c8d5f5c9932cf7394cd6135c0df280092bf3 | |
| parent | cfe03f2c74e3803ea3819ae5a1e6977bbdfc191e (diff) | |
| download | mullvadvpn-a8be84b07f8d04deca587b9a73133d9e0c802fe7.tar.xz mullvadvpn-a8be84b07f8d04deca587b9a73133d9e0c802fe7.zip | |
Add out of time view
| -rw-r--r-- | ios/MullvadVPN.xcodeproj/project.pbxproj | 16 | ||||
| -rw-r--r-- | ios/MullvadVPN/AccountViewController.swift | 61 | ||||
| -rw-r--r-- | ios/MullvadVPN/HeaderBarView.swift | 6 | ||||
| -rw-r--r-- | ios/MullvadVPN/OutOfTimeContentView.swift | 147 | ||||
| -rw-r--r-- | ios/MullvadVPN/OutOfTimeViewController.swift | 453 | ||||
| -rw-r--r-- | ios/MullvadVPN/REST/RESTCreateApplePaymentResponse+Localization.swift | 70 | ||||
| -rw-r--r-- | ios/MullvadVPN/SceneDelegate.swift | 154 | ||||
| -rw-r--r-- | ios/MullvadVPN/StatusActivityView.swift | 80 | ||||
| -rw-r--r-- | ios/MullvadVPN/UIColor+Palette.swift | 1 |
9 files changed, 903 insertions, 85 deletions
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index 92beeb5814..8d67e122ac 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -307,7 +307,11 @@ 58FEEB46260A028D00A621A8 /* GeoJSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FEEB45260A028D00A621A8 /* GeoJSON.swift */; }; 58FEEB58260B662E00A621A8 /* AutomaticKeyboardResponder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FEEB57260B662E00A621A8 /* AutomaticKeyboardResponder.swift */; }; 58FF2C03281BDE02009EF542 /* SettingsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FF2C02281BDE02009EF542 /* SettingsManager.swift */; }; + E1187ABC289BBB850024E748 /* OutOfTimeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1187ABA289BBB850024E748 /* OutOfTimeViewController.swift */; }; + E1187ABD289BBB850024E748 /* OutOfTimeContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1187ABB289BBB850024E748 /* OutOfTimeContentView.swift */; }; + E1187ABF289BE76F0024E748 /* RESTCreateApplePaymentResponse+Localization.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1187ABE289BE76F0024E748 /* RESTCreateApplePaymentResponse+Localization.swift */; }; E158B360285381C60002F069 /* StringFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E158B35F285381C60002F069 /* StringFormatter.swift */; }; + E1FD0DF528AA7CE400299DB4 /* StatusActivityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FD0DF428AA7CE400299DB4 /* StatusActivityView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -594,7 +598,11 @@ 58FEEB45260A028D00A621A8 /* GeoJSON.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeoJSON.swift; sourceTree = "<group>"; }; 58FEEB57260B662E00A621A8 /* AutomaticKeyboardResponder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomaticKeyboardResponder.swift; sourceTree = "<group>"; }; 58FF2C02281BDE02009EF542 /* SettingsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManager.swift; sourceTree = "<group>"; }; + E1187ABA289BBB850024E748 /* OutOfTimeViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OutOfTimeViewController.swift; sourceTree = "<group>"; }; + E1187ABB289BBB850024E748 /* OutOfTimeContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OutOfTimeContentView.swift; sourceTree = "<group>"; }; + E1187ABE289BE76F0024E748 /* RESTCreateApplePaymentResponse+Localization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RESTCreateApplePaymentResponse+Localization.swift"; sourceTree = "<group>"; }; E158B35F285381C60002F069 /* StringFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringFormatter.swift; sourceTree = "<group>"; }; + E1FD0DF428AA7CE400299DB4 /* StatusActivityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusActivityView.swift; sourceTree = "<group>"; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -774,6 +782,7 @@ 58554F72280AFA5A00013055 /* RESTAuthenticationProxy.swift */, 58B5A898280AB0D7009FDE99 /* RESTAuthorization.swift */, 585DA88926B027A300B8C587 /* RESTCoding.swift */, + E1187ABE289BE76F0024E748 /* RESTCreateApplePaymentResponse+Localization.swift */, 588BCF23280FE43D009ADCEC /* RESTDevicesProxy.swift */, 585DA88626B0277200B8C587 /* RESTError.swift */, 58095C522760EEC700890776 /* RESTNetworkOperation.swift */, @@ -920,6 +929,8 @@ 5871FB9F254C26BF0051A0A4 /* NSRegularExpression+IPAddress.swift */, 58CC40EE24A601900019D96E /* ObserverList.swift */, 580EE1FF24B3218800F9D8A1 /* Operations */, + E1187ABB289BBB850024E748 /* OutOfTimeContentView.swift */, + E1187ABA289BBB850024E748 /* OutOfTimeViewController.swift */, 584D26C3270C855A004EA533 /* PreferencesDataSource.swift */, 587EB6732714520600123C75 /* PreferencesDataSourceDelegate.swift */, 58ACF6482655365700ACE4B7 /* PreferencesViewController.swift */, @@ -955,6 +966,7 @@ 587A01FB23F1F0BE00B68763 /* SimulatorTunnelProviderHost.swift */, 58FD5BEF24238EB300112C88 /* SKProduct+Formatting.swift */, 58F19E34228C15BA00C7710B /* SpinnerActivityIndicatorView.swift */, + E1FD0DF428AA7CE400299DB4 /* StatusActivityView.swift */, 58EF581025D69DB400AEBA94 /* StatusImageView.swift */, 5807E2BF2432038B00F5FF30 /* String+Split.swift */, E158B35F285381C60002F069 /* StringFormatter.swift */, @@ -1350,7 +1362,9 @@ 58B3F30F2742708B00A2DD38 /* HeaderBarButton.swift in Sources */, 584789E026529D72000E45FB /* SSLPinningURLSessionDelegate.swift in Sources */, 58161C9C28352F850028ECFD /* MigrateSettingsOperation.swift in Sources */, + E1187ABF289BE76F0024E748 /* RESTCreateApplePaymentResponse+Localization.swift in Sources */, 58ACF6492655365700ACE4B7 /* PreferencesViewController.swift in Sources */, + E1FD0DF528AA7CE400299DB4 /* StatusActivityView.swift in Sources */, 58968FAE28743E2000B799DC /* TunnelInteractor.swift in Sources */, 588D2FE3248AC27F00E313F7 /* AsyncOperation.swift in Sources */, 5820675026E6514100655B05 /* HTTP.swift in Sources */, @@ -1368,6 +1382,7 @@ 58FEEB58260B662E00A621A8 /* AutomaticKeyboardResponder.swift in Sources */, 5846227326E22A160035F7C2 /* AppStorePaymentObserver.swift in Sources */, 58F2E146276A2C9900A79513 /* StopTunnelOperation.swift in Sources */, + E1187ABC289BBB850024E748 /* OutOfTimeViewController.swift in Sources */, 58DF5B762852108E00E92647 /* InputInjectionBuilder.swift in Sources */, 5856D13727450A8A00DFD627 /* UIImage+TintColor.swift in Sources */, 58CB0EE024B86751001EF0D8 /* RESTAPIProxy.swift in Sources */, @@ -1393,6 +1408,7 @@ 588527B4276B4F2F00BAA373 /* SetAccountOperation.swift in Sources */, 585CA70F25F8C44600B47C62 /* UIMetrics.swift in Sources */, 58F97A1B280EEBC00050C2FC /* RESTProxyFactory.swift in Sources */, + E1187ABD289BBB850024E748 /* OutOfTimeContentView.swift in Sources */, 58095C592762155700890776 /* RESTRetryStrategy.swift in Sources */, 5840250422B11AB700E4CFEC /* MullvadEndpoint.swift in Sources */, 58CC40EF24A601900019D96E /* ObserverList.swift in Sources */, diff --git a/ios/MullvadVPN/AccountViewController.swift b/ios/MullvadVPN/AccountViewController.swift index 13e5122ea0..82426b5623 100644 --- a/ios/MullvadVPN/AccountViewController.swift +++ b/ios/MullvadVPN/AccountViewController.swift @@ -422,67 +422,6 @@ class AccountViewController: UIViewController, AppStorePaymentObserver, TunnelOb } } -private extension REST.CreateApplePaymentResponse { - enum Context { - case purchase - case restoration - } - - func alertTitle(context: Context) -> String { - switch context { - case .purchase: - return NSLocalizedString( - "TIME_ADDED_ALERT_SUCCESS_TITLE", - tableName: "Account", - value: "Thanks for your purchase", - comment: "" - ) - case .restoration: - return NSLocalizedString( - "RESTORE_PURCHASES_ALERT_TITLE", - tableName: "Account", - value: "Restore purchases", - comment: "" - ) - } - } - - func alertMessage(context: Context) -> String { - switch context { - case .purchase: - return String( - format: NSLocalizedString( - "TIME_ADDED_ALERT_SUCCESS_MESSAGE", - tableName: "Account", - value: "%@ have been added to your account", - comment: "" - ), - formattedTimeAdded ?? "" - ) - case .restoration: - switch self { - case .noTimeAdded: - return NSLocalizedString( - "RESTORE_PURCHASES_ALERT_NO_TIME_ADDED_MESSAGE", - tableName: "Account", - value: "Your previous purchases have already been added to this account.", - comment: "" - ) - case .timeAdded: - return String( - format: NSLocalizedString( - "RESTORE_PURCHASES_ALERT_TIME_ADDED_MESSAGE", - tableName: "Account", - value: "%@ have been added to your account", - comment: "" - ), - formattedTimeAdded ?? "" - ) - } - } - } -} - private extension AccountViewController { enum PaymentState: Equatable { case none diff --git a/ios/MullvadVPN/HeaderBarView.swift b/ios/MullvadVPN/HeaderBarView.swift index 714c9bdab8..d27fb6ef6c 100644 --- a/ios/MullvadVPN/HeaderBarView.swift +++ b/ios/MullvadVPN/HeaderBarView.swift @@ -31,9 +31,15 @@ class HeaderBarView: UIView { class func makeSettingsButton() -> HeaderBarButton { let settingsImage = UIImage(named: "IconSettings")? .backport_withTintColor(UIColor.HeaderBar.buttonColor, renderingMode: .alwaysOriginal) + let disabledSettingsImage = UIImage(named: "IconSettings")? + .backport_withTintColor( + UIColor.HeaderBar.disabledButtonColor, + renderingMode: .alwaysOriginal + ) let settingsButton = HeaderBarButton(type: .system) settingsButton.setImage(settingsImage, for: .normal) + settingsButton.setImage(disabledSettingsImage, for: .disabled) settingsButton.translatesAutoresizingMaskIntoConstraints = false settingsButton.accessibilityIdentifier = "SettingsButton" settingsButton.accessibilityLabel = NSLocalizedString( diff --git a/ios/MullvadVPN/OutOfTimeContentView.swift b/ios/MullvadVPN/OutOfTimeContentView.swift new file mode 100644 index 0000000000..e4146902c0 --- /dev/null +++ b/ios/MullvadVPN/OutOfTimeContentView.swift @@ -0,0 +1,147 @@ +// +// OutOfTimeContentView.swift +// MullvadVPN +// +// Created by Andreas Lif on 2022-07-26. +// Copyright © 2022 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import UIKit + +class OutOfTimeContentView: UIView { + let statusActivityView: StatusActivityView = { + let statusActivityView = StatusActivityView(state: .failure) + statusActivityView.translatesAutoresizingMaskIntoConstraints = false + return statusActivityView + }() + + private lazy var titleLabel: UILabel = { + let label = UILabel() + label.text = NSLocalizedString( + "OUT_OF_TIME_TITLE", + tableName: "OutOfTime", + value: "Out of time", + comment: "" + ) + label.font = UIFont.systemFont(ofSize: 32) + label.textColor = .white + return label + }() + + lazy var bodyLabel: UILabel = { + let label = UILabel() + label.text = NSLocalizedString( + "OUT_OF_TIME_BODY", + tableName: "OutOfTime", + value: "You have no more VPN time left on this account. Either buy credit on our website or redeem a voucher.", + comment: "" + ) + label.font = UIFont.systemFont(ofSize: 17) + label.textColor = .white + label.numberOfLines = 0 + return label + }() + + lazy var disconnectButton: AppButton = { + let button = AppButton(style: .danger) + button.translatesAutoresizingMaskIntoConstraints = false + button.alpha = 0 + let localizedString = NSLocalizedString( + "OUT_OF_TIME_DISCONNECT_BUTTON", + tableName: "OutOfTime", + value: "Disconnect", + comment: "" + ) + button.setTitle(localizedString, for: .normal) + return button + }() + + lazy var purchaseButton: InAppPurchaseButton = { + let button = InAppPurchaseButton() + button.translatesAutoresizingMaskIntoConstraints = false + let localizedString = NSLocalizedString( + "OUT_OF_TIME_PURCHASE_BUTTON", + tableName: "OutOfTime", + value: "Add 30 days time", + comment: "" + ) + button.setTitle(localizedString, for: .normal) + return button + }() + + lazy var restoreButton: AppButton = { + let button = AppButton(style: .default) + button.translatesAutoresizingMaskIntoConstraints = false + button.setTitle(NSLocalizedString( + "RESTORE_PURCHASES_BUTTON_TITLE", + tableName: "OutOfTime", + value: "Restore purchases", + comment: "" + ), for: .normal) + return button + }() + + private lazy var topStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [statusActivityView, titleLabel, bodyLabel]) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.spacing = UIMetrics.sectionSpacing + return stackView + }() + + private lazy var bottomStackView: UIStackView = { + let stackView = UIStackView( + arrangedSubviews: [disconnectButton, purchaseButton, restoreButton] + ) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.spacing = UIMetrics.sectionSpacing + return stackView + }() + + override init(frame: CGRect) { + super.init(frame: frame) + translatesAutoresizingMaskIntoConstraints = false + backgroundColor = .secondaryColor + layoutMargins = UIMetrics.contentLayoutMargins + setUpSubviews() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Private Functions + +private extension OutOfTimeContentView { + func setUpSubviews() { + addSubview(topStackView) + addSubview(bottomStackView) + configureConstraints() + } + + func configureConstraints() { + NSLayoutConstraint.activate([ + topStackView.centerYAnchor.constraint(equalTo: centerYAnchor, constant: -20), + + topStackView.leadingAnchor.constraint( + equalTo: layoutMarginsGuide.leadingAnchor + ), + topStackView.trailingAnchor.constraint( + equalTo: layoutMarginsGuide.trailingAnchor + ), + + bottomStackView.leadingAnchor.constraint( + equalTo: layoutMarginsGuide.leadingAnchor + ), + bottomStackView.trailingAnchor.constraint( + equalTo: layoutMarginsGuide.trailingAnchor + ), + bottomStackView.bottomAnchor.constraint( + equalTo: layoutMarginsGuide.bottomAnchor + ), + ]) + } +} diff --git a/ios/MullvadVPN/OutOfTimeViewController.swift b/ios/MullvadVPN/OutOfTimeViewController.swift new file mode 100644 index 0000000000..5b0c7fe89c --- /dev/null +++ b/ios/MullvadVPN/OutOfTimeViewController.swift @@ -0,0 +1,453 @@ +// +// OutOfTimeViewController.swift +// MullvadVPN +// +// Created by Andreas Lif on 2022-07-25. +// Copyright © 2022 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import StoreKit +import UIKit + +class OutOfTimeViewController: UIViewController { + weak var delegate: SettingsButtonInteractionDelegate? + + private var productState: ProductState = .none + private var paymentState: PaymentState = .none + + private let alertPresenter = AlertPresenter() + + private lazy var contentView = OutOfTimeContentView() + + override var preferredStatusBarStyle: UIStatusBarStyle { + return .lightContent + } + + private var tunnelState: TunnelState = .disconnected { + didSet { + setNeedsHeaderBarStyleAppearanceUpdate() + applyViewState(animated: true) + } + } + + override func viewDidLoad() { + setUpContentView() + setUpButtonTargets() + setUpInAppPurchases() + addObservers() + tunnelState = TunnelManager.shared.tunnelStatus.state + } +} + +// MARK: - Private Functions + +private extension OutOfTimeViewController { + func setUpContentView() { + view.addSubview(contentView) + + NSLayoutConstraint.activate([ + contentView.topAnchor.constraint(equalTo: view.topAnchor), + contentView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + } + + func setUpButtonTargets() { + contentView.disconnectButton.addTarget( + self, + action: #selector(handleDisconnect(_:)), + for: .touchUpInside + ) + + contentView.purchaseButton.addTarget( + self, + action: #selector(doPurchase), + for: .touchUpInside + ) + contentView.restoreButton.addTarget( + self, + action: #selector(restorePurchases), + for: .touchUpInside + ) + } + + @objc func handleDisconnect(_ sender: Any) { + TunnelManager.shared.stopTunnel() + } + + func addObservers() { + AppStorePaymentManager.shared.addPaymentObserver(self) + TunnelManager.shared.addObserver(self) + } + + func setEnableUserInteraction(_ enableUserInteraction: Bool) { + [contentView.purchaseButton, contentView.restoreButton] + .forEach { button in + button?.isEnabled = enableUserInteraction + } + + view.isUserInteractionEnabled = enableUserInteraction + } + + func bodyText(for tunnelState: TunnelState) -> String { + if tunnelState.isSecured { + return NSLocalizedString( + "OUT_OF_TIME_BODY_CONNECTED", + tableName: "OutOfTime", + value: "You have no more VPN time left on this account. To add more, you will need to disconnect and access the Internet with an unsecure connection.", + comment: "" + ) + } else { + return NSLocalizedString( + "OUT_OF_TIME_BODY_DISCONNECTED", + tableName: "OutOfTime", + value: "You have no more VPN time left on this account. Either buy credit on our website or redeem a voucher.", + comment: "" + ) + } + } +} + +// MARK: - In App Purchases + +private extension OutOfTimeViewController { + func setUpInAppPurchases() { + if AppStorePaymentManager.canMakePayments { + requestStoreProducts() + } else { + setProductState(.cannotMakePurchases, animated: false) + } + } + + 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) + } + } + + func setPaymentState(_ newState: PaymentState, animated: Bool) { + paymentState = newState + + applyViewState(animated: animated) + } + + func setProductState(_ newState: ProductState, animated: Bool) { + productState = newState + + applyViewState(animated: animated) + } + + func applyViewState(animated: Bool) { + let isInteractionEnabled = paymentState.allowsViewInteraction + let purchaseButton = contentView.purchaseButton + + let isOutOfTime = TunnelManager.shared.deviceState.accountData + .map { $0.expiry < Date() } ?? false + + let actions = { [weak self] in + guard let self = self else { return } + + purchaseButton.setTitle(self.productState.purchaseButtonTitle, for: .normal) + self.contentView.purchaseButton.isLoading = self.productState.isFetching + + purchaseButton.isEnabled = self.productState.isReceived && isInteractionEnabled && !self + .tunnelState.isSecured + self.contentView.restoreButton.isEnabled = isInteractionEnabled + self.contentView.disconnectButton.isEnabled = self.tunnelState.isSecured + self.contentView.disconnectButton.alpha = self.tunnelState.isSecured ? 1 : 0 + self.contentView.bodyLabel.text = self.bodyText(for: self.tunnelState) + + if !isInteractionEnabled { + self.contentView.statusActivityView.state = .activity + } else { + self.contentView.statusActivityView.state = isOutOfTime ? .failure : .success + } + + self.delegate?.viewController( + self, + didRequestSettingsButtonEnabled: isInteractionEnabled + ) + } + if animated { + UIView.animate(withDuration: 0.25, animations: actions) + } else { + actions() + } + + view.isUserInteractionEnabled = isInteractionEnabled + if #available(iOS 13.0, *) { + isModalInPresentation = !isInteractionEnabled + } + navigationItem.setHidesBackButton(!isInteractionEnabled, animated: animated) + } + + @objc private func doPurchase() { + guard case let .received(product) = productState, + let accountData = TunnelManager.shared.deviceState.accountData + else { + return + } + + let payment = SKPayment(product: product) + AppStorePaymentManager.shared.addPayment(payment, for: accountData.number) + + setPaymentState(.makingPayment(payment), animated: true) + } + + @objc func restorePurchases() { + guard let accountData = TunnelManager.shared.deviceState.accountData else { + return + } + + setPaymentState(.restoringPurchases, animated: true) + + _ = AppStorePaymentManager.shared.restorePurchases(for: accountData.number) { completion in + switch completion { + case let .success(response): + self.showAlertIfNoTimeAdded(with: response, context: .restoration) + case let .failure(error): + self.showRestorePurchasesErrorAlert(error: error) + + case .cancelled: + break + } + + self.setPaymentState(.none, animated: true) + } + } + + private func showAlertIfNoTimeAdded( + with response: REST.CreateApplePaymentResponse, + context: REST.CreateApplePaymentResponse.Context + ) { + guard case .noTimeAdded = response else { return } + + let alertController = UIAlertController( + title: response.alertTitle(context: context), + message: response.alertMessage(context: context), + preferredStyle: .alert + ) + alertController.addAction( + UIAlertAction( + title: NSLocalizedString( + "TIME_ADDED_ALERT_OK_ACTION", + tableName: "OutOfTime", + value: "OK", + comment: "" + ), + style: .cancel + ) + ) + + alertPresenter.enqueue(alertController, presentingController: self) + } + + func showRestorePurchasesErrorAlert(error: AppStorePaymentManager.Error) { + let alertController = UIAlertController( + title: NSLocalizedString( + "RESTORE_PURCHASES_FAILURE_ALERT_TITLE", + tableName: "OutOfTime", + value: "Cannot restore purchases", + comment: "" + ), + message: error.errorChainDescription, + preferredStyle: .alert + ) + alertController.addAction( + UIAlertAction(title: NSLocalizedString( + "RESTORE_PURCHASES_FAILURE_ALERT_OK_ACTION", + tableName: "OutOfTime", + value: "OK", + comment: "" + ), style: .cancel) + ) + alertPresenter.enqueue(alertController, presentingController: self) + } + + func showPaymentErrorAlert(error: AppStorePaymentManager.Error) { + let alertController = UIAlertController( + title: NSLocalizedString( + "CANNOT_COMPLETE_PURCHASE_ALERT_TITLE", + tableName: "OutOfTime", + value: "Cannot complete the purchase", + comment: "" + ), + message: error.errorChainDescription, + preferredStyle: .alert + ) + + alertController.addAction( + UIAlertAction( + title: NSLocalizedString( + "CANNOT_COMPLETE_PURCHASE_ALERT_OK_ACTION", + tableName: "OutOfTime", + value: "OK", + comment: "" + ), style: .cancel + ) + ) + + alertPresenter.enqueue(alertController, presentingController: self) + } + + func didProcessPayment(_ payment: SKPayment) { + guard case let .makingPayment(pendingPayment) = paymentState, + pendingPayment == payment else { return } + + setPaymentState(.none, animated: true) + } +} + +// MARK: - AppStorePaymentObserver + +extension OutOfTimeViewController: AppStorePaymentObserver { + func appStorePaymentManager( + _ manager: AppStorePaymentManager, + transaction: SKPaymentTransaction?, + payment: SKPayment, + accountToken: String?, + didFailWithError error: AppStorePaymentManager.Error + ) { + switch error { + case .storePayment(SKError.paymentCancelled): + break + + default: + showPaymentErrorAlert(error: error) + } + + didProcessPayment(payment) + } + + func appStorePaymentManager( + _ manager: AppStorePaymentManager, + transaction: SKPaymentTransaction, + accountToken: String, + didFinishWithResponse response: REST.CreateApplePaymentResponse + ) { + didProcessPayment(transaction.payment) + } +} + +// MARK: - TunnelObserver + +extension OutOfTimeViewController: TunnelObserver { + func tunnelManagerDidLoadConfiguration(_ manager: TunnelManager) {} + + func tunnelManager(_ manager: TunnelManager, didUpdateTunnelState tunnelState: TunnelState) { + self.tunnelState = tunnelState + } + + func tunnelManager(_ manager: TunnelManager, didUpdateDeviceState deviceState: DeviceState) {} + + func tunnelManager( + _ manager: TunnelManager, + didUpdateTunnelSettings tunnelSettings: TunnelSettingsV2 + ) {} + + func tunnelManager(_ manager: TunnelManager, didFailWithError error: Error) {} +} + +// MARK: - Header Bar + +extension OutOfTimeViewController: RootContainment { + var preferredHeaderBarPresentation: HeaderBarPresentation { + return HeaderBarPresentation( + style: tunnelState.isSecured ? .secured : .unsecured, + showsDivider: false + ) + } + + var prefersHeaderBarHidden: Bool { + false + } +} + +// MARK: - UI Restrictions + +private extension OutOfTimeViewController { + 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: "OutOfTime", + value: "%1$@ (%2$@)", + comment: "" + ) + return String(format: format, localizedTitle, localizedPrice) + + case .failed: + return NSLocalizedString( + "PURCHASE_BUTTON_CANNOT_CONNECT_TO_APPSTORE_LABEL", + tableName: "OutOfTime", + value: "Cannot connect to AppStore", + comment: "" + ) + + case .cannotMakePurchases: + return NSLocalizedString( + "PURCHASE_BUTTON_PAYMENTS_RESTRICTED_LABEL", + tableName: "OutOfTime", + value: "Payments restricted", + comment: "" + ) + } + } + } +} diff --git a/ios/MullvadVPN/REST/RESTCreateApplePaymentResponse+Localization.swift b/ios/MullvadVPN/REST/RESTCreateApplePaymentResponse+Localization.swift new file mode 100644 index 0000000000..68c2a3ec68 --- /dev/null +++ b/ios/MullvadVPN/REST/RESTCreateApplePaymentResponse+Localization.swift @@ -0,0 +1,70 @@ +// +// RESTCreateApplePaymentResponse.swift +// MullvadVPN +// +// Created by Andreas Lif on 2022-08-04. +// Copyright © 2022 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +extension REST.CreateApplePaymentResponse { + enum Context { + case purchase + case restoration + } + + func alertTitle(context: Context) -> String { + switch context { + case .purchase: + return NSLocalizedString( + "TIME_ADDED_ALERT_SUCCESS_TITLE", + tableName: "REST", + value: "Thanks for your purchase", + comment: "" + ) + case .restoration: + return NSLocalizedString( + "RESTORE_PURCHASES_ALERT_TITLE", + tableName: "REST", + value: "Restore purchases", + comment: "" + ) + } + } + + func alertMessage(context: Context) -> String { + switch context { + case .purchase: + return String( + format: NSLocalizedString( + "TIME_ADDED_ALERT_SUCCESS_MESSAGE", + tableName: "REST", + value: "%@ have been added to your account", + comment: "" + ), + formattedTimeAdded ?? "" + ) + case .restoration: + switch self { + case .noTimeAdded: + return NSLocalizedString( + "RESTORE_PURCHASES_ALERT_NO_TIME_ADDED_MESSAGE", + tableName: "REST", + value: "Your previous purchases have already been added to this account.", + comment: "" + ) + case .timeAdded: + return String( + format: NSLocalizedString( + "RESTORE_PURCHASES_ALERT_TIME_ADDED_MESSAGE", + tableName: "REST", + value: "%@ have been added to your account", + comment: "" + ), + formattedTimeAdded ?? "" + ) + } + } + } +} diff --git a/ios/MullvadVPN/SceneDelegate.swift b/ios/MullvadVPN/SceneDelegate.swift index d07b019e34..5899e3ac66 100644 --- a/ios/MullvadVPN/SceneDelegate.swift +++ b/ios/MullvadVPN/SceneDelegate.swift @@ -28,6 +28,7 @@ class SceneDelegate: UIResponder { private weak var settingsNavController: SettingsNavigationController? private var lastLoginAction: LoginAction? private var accountDataThrottling = AccountDataThrottling() + private var outOfTimeTimer: Timer? override init() { super.init() @@ -35,6 +36,23 @@ class SceneDelegate: UIResponder { addSceneEvents() } + deinit { + clearOutOfTimeTimer() + } + + var isShowingOutOfTimeView: Bool { + switch UIDevice.current.userInterfaceIdiom { + case .pad: + return modalRootContainer.viewControllers + .contains(where: { $0 is OutOfTimeViewController }) + case .phone: + return rootContainer.viewControllers + .contains(where: { $0 is OutOfTimeViewController }) + default: + return false + } + } + func setupScene(windowFactory: WindowFactory) { window = windowFactory.create() window?.rootViewController = LaunchViewController() @@ -185,6 +203,24 @@ extension SceneDelegate: UIWindowSceneDelegate { } } +// MARK: - SettingsButtonInteractionDelegate + +protocol SettingsButtonInteractionDelegate: AnyObject { + func viewController( + _ controller: UIViewController, + didRequestSettingsButtonEnabled isEnabled: Bool + ) +} + +extension SceneDelegate: SettingsButtonInteractionDelegate { + func viewController( + _ controller: UIViewController, + didRequestSettingsButtonEnabled isEnabled: Bool + ) { + setEnableSettingsButton(isEnabled: isEnabled, from: controller) + } +} + // MARK: - RootContainerViewControllerDelegate extension SceneDelegate: RootContainerViewControllerDelegate { @@ -277,9 +313,14 @@ extension SceneDelegate { switch tunnelManager.deviceState { case .loggedIn: let didDismissModalRoot = { - self.showAccountSettingsControllerIfAccountExpired() + self.handleExpiredAccount() } + self.modalRootContainer.setViewControllers( + viewControllers, + animated: self.isModalRootPresented && animated + ) + // Dismiss modal root container if needed before proceeding. if self.isModalRootPresented { self.modalRootContainer.dismiss( @@ -289,7 +330,6 @@ extension SceneDelegate { } else { didDismissModalRoot() } - return case .loggedOut: @@ -343,14 +383,13 @@ extension SceneDelegate { let showNextController = { [weak self] (animated: Bool) in guard let self = self else { return } - let loginViewController = self.makeLoginController() - var viewControllers: [UIViewController] = [loginViewController] + var viewControllers: [UIViewController] = [self.makeLoginController()] switch TunnelManager.shared.deviceState { case .loggedIn: let connectController = self.makeConnectViewController() - viewControllers.append(connectController) self.connectController = connectController + viewControllers.append(connectController) case .loggedOut: break @@ -360,7 +399,7 @@ extension SceneDelegate { } self.rootContainer.setViewControllers(viewControllers, animated: animated) { - self.showAccountSettingsControllerIfAccountExpired() + self.handleExpiredAccount() } } @@ -370,7 +409,6 @@ extension SceneDelegate { let termsOfServiceController = makeTermsOfServiceController { _ in showNextController(true) } - rootContainer.setViewControllers([termsOfServiceController], animated: false) } } @@ -395,6 +433,12 @@ extension SceneDelegate { return navController } + private func makeOutOfTimeViewController() -> OutOfTimeViewController { + let viewController = OutOfTimeViewController() + viewController.delegate = self + return viewController + } + private func makeConnectViewController() -> ConnectViewController { let connectController = ConnectViewController() connectController.delegate = self @@ -453,13 +497,27 @@ extension SceneDelegate { return controller } - private func showAccountSettingsControllerIfAccountExpired() { - guard case let .loggedIn(accountData, _) = TunnelManager.shared.deviceState else { - return - } + private func handleExpiredAccount() { + guard case let .loggedIn(accountData, _) = TunnelManager.shared.deviceState, + accountData.expiry <= Date() else { return } - if accountData.expiry <= Date() { - rootContainer.showSettings(navigateTo: .account, animated: true) + switch UIDevice.current.userInterfaceIdiom { + case .phone: + if !rootContainer.viewControllers.contains(where: { $0 is OutOfTimeViewController }) { + rootContainer.pushViewController(makeOutOfTimeViewController(), animated: false) + } + case .pad: + if !modalRootContainer.viewControllers + .contains(where: { $0 is OutOfTimeViewController }) + { + modalRootContainer.pushViewController( + makeOutOfTimeViewController(), + animated: false + ) + presentModalRootContainerIfNeeded(animated: true) + } + default: + return } } @@ -478,14 +536,14 @@ extension SceneDelegate { dismissController?.dismiss(animated: true) case .pad: + let loginController = modalRootContainer.viewControllers.first as? LoginViewController + loginController?.reset() + let didDismissSourceController = { self.presentModalRootContainerIfNeeded(animated: true) } - let loginController = modalRootContainer.viewControllers.first as? LoginViewController - loginController?.reset() - - modalRootContainer.popToRootViewController(animated: isModalRootPresented) + modalRootContainer.popToRootViewController(animated: false) showSplitViewMaster(false, animated: true) if let dismissController = dismissController { @@ -495,7 +553,22 @@ extension SceneDelegate { } default: - fatalError() + return + } + } + + private func dismissOutOfTimeController() { + switch UIDevice.current.userInterfaceIdiom { + case .phone: + var viewControllers = rootContainer.viewControllers + guard let outOfTimeControllerIndex = viewControllers + .firstIndex(where: { $0 is OutOfTimeViewController }) else { return } + viewControllers.remove(at: outOfTimeControllerIndex) + rootContainer.setViewControllers(viewControllers, animated: true) + case .pad: + modalRootContainer.dismiss(animated: true) + default: + return } } @@ -618,21 +691,49 @@ extension SceneDelegate: LoginViewControllerDelegate { switch UIDevice.current.userInterfaceIdiom { case .phone: let connectController = makeConnectViewController() - rootContainer.pushViewController(connectController, animated: true) { - self.showAccountSettingsControllerIfAccountExpired() - } self.connectController = connectController + var viewControllers = rootContainer.viewControllers + viewControllers.append(connectController) + rootContainer.setViewControllers(viewControllers, animated: true) + handleExpiredAccount() case .pad: showSplitViewMaster(true, animated: true) controller.dismiss(animated: true) { - self.showAccountSettingsControllerIfAccountExpired() + self.handleExpiredAccount() } default: fatalError() } } + private func setUpOutOfTimeTimer() { + outOfTimeTimer?.invalidate() + + guard case let .loggedIn(accountData, _) = TunnelManager.shared.deviceState, + accountData.expiry > Date() else { return } + + let timer = Timer( + fire: accountData.expiry, + interval: 0, + repeats: false + ) { [weak self] _ in + self?.outOfTimeTimerDidFire() + } + + outOfTimeTimer = timer + RunLoop.main.add(timer, forMode: .common) + } + + @objc func outOfTimeTimerDidFire() { + handleExpiredAccount() + } + + private func clearOutOfTimeTimer() { + outOfTimeTimer?.invalidate() + outOfTimeTimer = nil + } + private func setEnableSettingsButton(isEnabled: Bool, from viewController: UIViewController?) { let containers = [viewController?.rootContainerController, rootContainer].compactMap { $0 } @@ -844,8 +945,13 @@ extension SceneDelegate: TunnelObserver { func tunnelManager(_ manager: TunnelManager, didUpdateDeviceState deviceState: DeviceState) { switch deviceState { - case .loggedIn: - break + case let .loggedIn(accountData, _): + if accountData.expiry > Date(), + isShowingOutOfTimeView + { + dismissOutOfTimeController() + setUpOutOfTimeTimer() + } case .loggedOut: accountDataThrottling.reset() diff --git a/ios/MullvadVPN/StatusActivityView.swift b/ios/MullvadVPN/StatusActivityView.swift new file mode 100644 index 0000000000..c44cbf82fb --- /dev/null +++ b/ios/MullvadVPN/StatusActivityView.swift @@ -0,0 +1,80 @@ +// +// StatusActivityView.swift +// MullvadVPN +// +// Created by Andreas Lif on 2022-08-15. +// Copyright © 2022 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import UIKit + +class StatusActivityView: UIView { + enum State { + case hidden, activity, success, failure + } + + var state: State = .hidden { + didSet { + updateView() + } + } + + private let statusImageView: StatusImageView = { + let imageView = StatusImageView(style: .failure) + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.contentMode = .scaleAspectFit + return imageView + }() + + private let activityIndicator: SpinnerActivityIndicatorView = { + let view = SpinnerActivityIndicatorView(style: .large) + view.tintColor = .white + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + init(state: State) { + super.init(frame: .zero) + + self.state = state + addSubview(statusImageView) + addSubview(activityIndicator) + + NSLayoutConstraint.activate([ + activityIndicator.heightAnchor.constraint(equalTo: statusImageView.heightAnchor), + statusImageView.topAnchor.constraint(equalTo: topAnchor), + statusImageView.bottomAnchor.constraint(equalTo: bottomAnchor), + + statusImageView.centerXAnchor.constraint(equalTo: centerXAnchor), + statusImageView.centerYAnchor.constraint(equalTo: centerYAnchor), + activityIndicator.centerXAnchor.constraint(equalTo: centerXAnchor), + activityIndicator.centerYAnchor.constraint(equalTo: centerYAnchor), + ]) + + updateView() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + } + + private func updateView() { + switch state { + case .hidden: + statusImageView.alpha = 0 + activityIndicator.stopAnimating() + case .activity: + statusImageView.alpha = 0 + activityIndicator.startAnimating() + case .success: + statusImageView.alpha = 1 + statusImageView.style = .success + activityIndicator.stopAnimating() + case .failure: + statusImageView.alpha = 1 + statusImageView.style = .failure + activityIndicator.stopAnimating() + } + } +} diff --git a/ios/MullvadVPN/UIColor+Palette.swift b/ios/MullvadVPN/UIColor+Palette.swift index 0617868656..d7be5b728d 100644 --- a/ios/MullvadVPN/UIColor+Palette.swift +++ b/ios/MullvadVPN/UIColor+Palette.swift @@ -98,6 +98,7 @@ extension UIColor { static let dividerColor = secondaryColor static let brandNameColor = UIColor(white: 1.0, alpha: 0.8) static let buttonColor = UIColor(white: 1.0, alpha: 0.8) + static let disabledButtonColor = UIColor(white: 1.0, alpha: 0.5) } enum InAppNotificationBanner { |
