summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAndreas Lif <andreas.lif@shortcut.io>2022-08-04 11:29:23 +0200
committerAndreas Lif <andreas.lif@shortcut.io>2022-08-16 13:44:35 +0200
commita8be84b07f8d04deca587b9a73133d9e0c802fe7 (patch)
treed943c8d5f5c9932cf7394cd6135c0df280092bf3
parentcfe03f2c74e3803ea3819ae5a1e6977bbdfc191e (diff)
downloadmullvadvpn-a8be84b07f8d04deca587b9a73133d9e0c802fe7.tar.xz
mullvadvpn-a8be84b07f8d04deca587b9a73133d9e0c802fe7.zip
Add out of time view
-rw-r--r--ios/MullvadVPN.xcodeproj/project.pbxproj16
-rw-r--r--ios/MullvadVPN/AccountViewController.swift61
-rw-r--r--ios/MullvadVPN/HeaderBarView.swift6
-rw-r--r--ios/MullvadVPN/OutOfTimeContentView.swift147
-rw-r--r--ios/MullvadVPN/OutOfTimeViewController.swift453
-rw-r--r--ios/MullvadVPN/REST/RESTCreateApplePaymentResponse+Localization.swift70
-rw-r--r--ios/MullvadVPN/SceneDelegate.swift154
-rw-r--r--ios/MullvadVPN/StatusActivityView.swift80
-rw-r--r--ios/MullvadVPN/UIColor+Palette.swift1
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 {