diff options
| author | Andrej Mihajlov <and@mullvad.net> | 2022-08-16 13:49:52 +0200 |
|---|---|---|
| committer | Andrej Mihajlov <and@mullvad.net> | 2022-08-16 13:49:52 +0200 |
| commit | affa7a6e81a82a88abcf7a6bddf3c6e67f3d2a3d (patch) | |
| tree | fb4c2fd8d5d2eddf93859402d84804009c64f8c7 | |
| parent | cfe03f2c74e3803ea3819ae5a1e6977bbdfc191e (diff) | |
| parent | c468945c30380c7a19d76acabcb5fc65a7d801db (diff) | |
| download | mullvadvpn-affa7a6e81a82a88abcf7a6bddf3c6e67f3d2a3d.tar.xz mullvadvpn-affa7a6e81a82a88abcf7a6bddf3c6e67f3d2a3d.zip | |
Merge branch 'add-out-of-time-view'
| -rw-r--r-- | ios/CHANGELOG.md | 28 | ||||
| -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 |
10 files changed, 919 insertions, 97 deletions
diff --git a/ios/CHANGELOG.md b/ios/CHANGELOG.md index d3100508f0..1e3ddfb074 100644 --- a/ios/CHANGELOG.md +++ b/ios/CHANGELOG.md @@ -30,9 +30,13 @@ Line wrap the file at 100 chars. Th - Add revoked device view displayed when the app detects that device is no longer registered on backend. - Add ability to manage registered devices if too many devices detected during log-in. -- Add intents: start VPN, stop VPN, reconnect VPN (acts as start VPN when the tunnel is down, +- Add intents: start VPN, stop VPN, reconnect VPN (acts as start VPN when the tunnel is down, otherwise picks new relay). +### Changed +- When logged into an account with no time left, a new view is shown instead of account settings, +with the option to buy more time. + ### Fixed - Improve random port distribution. Should be less biased towards port 53. - Fix invalid map camera position during the app launch and keep it up to date when multitasking. @@ -49,8 +53,8 @@ Line wrap the file at 100 chars. Th - Delete leftover settings in Keychain during login. WireGuard keys will be removed from server too if old settings can be read. This is usually the case when uninstalling the app and then reinstalling it without logging out first. -- Validate account token before charging user (in-app purchases). Safeguards from trying to add - credits on accounts that no longer exist on our backend. Usually the case with newly created +- Validate account token before charging user (in-app purchases). Safeguards from trying to add + credits on accounts that no longer exist on our backend. Usually the case with newly created accounts that went stale. @@ -62,13 +66,13 @@ Line wrap the file at 100 chars. Th ### Fixed - Fix crash occurring after completing in-app purchase. - Fix error when changing relays while in airplane mode. -- Prevent key rotation from clogging the server key list by storing the next key and reusing it +- Prevent key rotation from clogging the server key list by storing the next key and reusing it until receiving the successful response from Mullvad API. Add up to three retry attempts. ### Changed - Increase hit area of settings (cog) button. - Update launch screen. -- Never use DNS to talk to Mullvad API. Instead use the list of IP addresses bundled with the app +- Never use DNS to talk to Mullvad API. Instead use the list of IP addresses bundled with the app and update it periodically. @@ -86,7 +90,7 @@ Line wrap the file at 100 chars. Th - Drop leading replacement characters (`\u{FFFD}`) when decoding UTF-8 from a part of log file. ### Security -- Move REST API networking from the packet tunnel process to the main process to prevent leaking +- Move REST API networking from the packet tunnel process to the main process to prevent leaking traffic outside of the tunnel. @@ -106,7 +110,7 @@ Line wrap the file at 100 chars. Th - Enable option to "Select all" when viewing app logs. - Split view interface for iPad. - Add interactive map. -- Reduce network traffic consumption by leveraging HTTP caching via ETag HTTP header to avoid +- Reduce network traffic consumption by leveraging HTTP caching via ETag HTTP header to avoid re-downloading the relay list if it hasn't changed. - Pin root SSL certificates. - Add option to use Mullvad's ad-blocking DNS servers. @@ -134,7 +138,7 @@ Line wrap the file at 100 chars. Th ## [2020.5] - 2020-11-04 ### Fixed -- Fix regression where "Internal error" was displayed instead of server error (i.e too many +- Fix regression where "Internal error" was displayed instead of server error (i.e too many WireGuard keys) @@ -164,11 +168,11 @@ Line wrap the file at 100 chars. Th - Add automatic key rotation every 4 days. ### Fixed -- Fix relay selection for country wide constraints by respecting the `include_in_country` +- Fix relay selection for country wide constraints by respecting the `include_in_country` parameter. - Fix defect when manually regenerating the private key from Settings would automatically connect the tunnel. -- Properly format date intervals close to 1 day or less than 1 minute. Enforce intervals between 1 +- Properly format date intervals close to 1 day or less than 1 minute. Enforce intervals between 1 and 90 days to always be displayed in days quantity. - Fix a number of errors in DNS64 resolution and IPv6 support. - Update the tunnel state when the app returns from suspended state. @@ -184,8 +188,8 @@ Line wrap the file at 100 chars. Th ### Added - Format account number in groups of 4 digits separated by whitespace on login screen. -- Enable on-demand VPN with a single rule to always connect the tunnel when on Wi-Fi or cellular. - Automatically disable on-demand VPN when manually disconnecting the tunnel from GUI to prevent the +- Enable on-demand VPN with a single rule to always connect the tunnel when on Wi-Fi or cellular. + Automatically disable on-demand VPN when manually disconnecting the tunnel from GUI to prevent the tunnel from coming back up. 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 { |
