summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAndrej Mihajlov <and@mullvad.net>2020-03-10 16:21:54 +0100
committerAndrej Mihajlov <and@mullvad.net>2020-03-30 12:19:11 +0200
commitf66a2870876c7b4127206373e90c8dea33bec63d (patch)
tree22e3357f61af932bcf5c76e0d76b95e79918679b
parent672da7a7c5eca2e6f0697cf3385bc94366440d1f (diff)
downloadmullvadvpn-f66a2870876c7b4127206373e90c8dea33bec63d.tar.xz
mullvadvpn-f66a2870876c7b4127206373e90c8dea33bec63d.zip
Add in-app purchases UI
-rw-r--r--ios/MullvadVPN.xcodeproj/project.pbxproj20
-rw-r--r--ios/MullvadVPN/AccountViewController.swift201
-rw-r--r--ios/MullvadVPN/Base.lproj/Main.storyboard120
-rw-r--r--ios/MullvadVPN/InAppPurchaseButton.swift47
-rw-r--r--ios/MullvadVPN/JsonRpc.swift4
-rw-r--r--ios/MullvadVPN/SKProduct+Formatting.swift22
-rw-r--r--ios/MullvadVPN/UserInterfaceInteractionRestriction.swift90
7 files changed, 463 insertions, 41 deletions
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj
index b6565b3dd6..9fb61664a7 100644
--- a/ios/MullvadVPN.xcodeproj/project.pbxproj
+++ b/ios/MullvadVPN.xcodeproj/project.pbxproj
@@ -28,10 +28,10 @@
5845F842236CBACD00B2D93C /* PacketTunnelIpc.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5845F841236CBACD00B2D93C /* PacketTunnelIpc.swift */; };
5845F843236CBDAB00B2D93C /* PacketTunnelIpc.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5845F841236CBACD00B2D93C /* PacketTunnelIpc.swift */; };
584B26FF237435A90073B10E /* RelaySelector+RelayCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 584B26FD237435990073B10E /* RelaySelector+RelayCache.swift */; };
+ 584E96BA240D791E00D3334F /* CancellableDelayPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 584E96B9240D791E00D3334F /* CancellableDelayPublisher.swift */; };
584E96BC240FD4DA00D3334F /* Location.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58A1AA8623F43901009F7EA6 /* Location.swift */; };
584E96BD240FD4DA00D3334F /* Location.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58A1AA8623F43901009F7EA6 /* Location.swift */; };
584E96BE240FD4DB00D3334F /* Location.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58A1AA8623F43901009F7EA6 /* Location.swift */; };
- 584E96BA240D791E00D3334F /* CancellableDelayPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 584E96B9240D791E00D3334F /* CancellableDelayPublisher.swift */; };
58561C99239A5D1500BD6B5E /* IPEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58561C98239A5D1500BD6B5E /* IPEndpoint.swift */; };
58561C9A239A5D1500BD6B5E /* IPEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58561C98239A5D1500BD6B5E /* IPEndpoint.swift */; };
5860F1C223A785C600CEA666 /* WireguardDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5860F1C123A785C600CEA666 /* WireguardDevice.swift */; };
@@ -126,6 +126,9 @@
58FD5BE724192A2C00112C88 /* AppStoreReceipt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FD5BE624192A2B00112C88 /* AppStoreReceipt.swift */; };
58FD5BE92419406000112C88 /* SKRequestPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FD5BE82419406000112C88 /* SKRequestPublisher.swift */; };
58FD5BEC2420F58A00112C88 /* SKPaymentQueuePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FD5BEB2420F58A00112C88 /* SKPaymentQueuePublisher.swift */; };
+ 58FD5BF024238EB300112C88 /* SKProduct+Formatting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FD5BEF24238EB300112C88 /* SKProduct+Formatting.swift */; };
+ 58FD5BF22424F7D700112C88 /* UserInterfaceInteractionRestriction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FD5BF12424F7D700112C88 /* UserInterfaceInteractionRestriction.swift */; };
+ 58FD5BF42428C67600112C88 /* InAppPurchaseButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FD5BF32428C67600112C88 /* InAppPurchaseButton.swift */; };
58FD5BF624291F1A00112C88 /* AppStorePaymentPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FD5BF524291F1A00112C88 /* AppStorePaymentPublisher.swift */; };
/* End PBXBuildFile section */
@@ -267,6 +270,9 @@
58FD5BE624192A2B00112C88 /* AppStoreReceipt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreReceipt.swift; sourceTree = "<group>"; };
58FD5BE82419406000112C88 /* SKRequestPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SKRequestPublisher.swift; sourceTree = "<group>"; };
58FD5BEB2420F58A00112C88 /* SKPaymentQueuePublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SKPaymentQueuePublisher.swift; sourceTree = "<group>"; };
+ 58FD5BEF24238EB300112C88 /* SKProduct+Formatting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SKProduct+Formatting.swift"; sourceTree = "<group>"; };
+ 58FD5BF12424F7D700112C88 /* UserInterfaceInteractionRestriction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserInterfaceInteractionRestriction.swift; sourceTree = "<group>"; };
+ 58FD5BF32428C67600112C88 /* InAppPurchaseButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppPurchaseButton.swift; sourceTree = "<group>"; };
58FD5BF524291F1A00112C88 /* AppStorePaymentPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStorePaymentPublisher.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
@@ -359,19 +365,21 @@
5868585424054096000B8131 /* AppButton.swift */,
58CE5E63224146200008646E /* AppDelegate.swift */,
58BFA5CB22A7CE1F00A6173D /* ApplicationConfiguration.swift */,
- 58FD5BE624192A2B00112C88 /* AppStoreReceipt.swift */,
58DF28A42417CB4B00E836B0 /* AppStorePaymentManager.swift */,
58FD5BF524291F1A00112C88 /* AppStorePaymentPublisher.swift */,
+ 58FD5BE624192A2B00112C88 /* AppStoreReceipt.swift */,
58CE5E6A224146210008646E /* Assets.xcassets */,
5845F839236C6A7200B2D93C /* AutoDisposableSink.swift */,
589AB4F6227B64450039131E /* BasicTableViewCell.swift */,
58EC4E6B23915325003F5C5B /* Bundle+MullvadVersion.swift */,
+ 584E96B9240D791E00D3334F /* CancellableDelayPublisher.swift */,
58A1AA8B23F5584B009F7EA6 /* ConnectionPanelView.swift */,
58CCA00F224249A1004F3011 /* ConnectViewController.swift */,
58A99ED2240014A0006599E9 /* ConsentViewController.swift */,
582BB1B0229569620055B6EF /* CustomNavigationBar.swift */,
58C6B35D22BBBFE3003C19AD /* Data+HexCoding.swift */,
5873884C239E6D7E00E96C4E /* EmbeddedViewContainerView.swift */,
+ 58FD5BF32428C67600112C88 /* InAppPurchaseButton.swift */,
58CE5E6F224146210008646E /* Info.plist */,
5840250022B1124600E4CFEC /* IpAddress+Codable.swift */,
58C6B34E22BB7AC0003C19AD /* IPAddressRange.swift */,
@@ -409,8 +417,9 @@
58CCA01122424D11004F3011 /* SettingsViewController.swift */,
58BA693023EADA6A009DC256 /* SimulatorTunnelProvider.swift */,
587A01FB23F1F0BE00B68763 /* SimulatorTunnelProviderHost.swift */,
- 58FD5BE82419406000112C88 /* SKRequestPublisher.swift */,
58FD5BEB2420F58A00112C88 /* SKPaymentQueuePublisher.swift */,
+ 58FD5BEF24238EB300112C88 /* SKProduct+Formatting.swift */,
+ 58FD5BE82419406000112C88 /* SKRequestPublisher.swift */,
58F19E34228C15BA00C7710B /* SpinnerActivityIndicatorView.swift */,
581CBCED229826FD00727D7F /* StaticTableViewDataSource.swift */,
5862805322428EF100F5A6E1 /* TranslucentButtonBlurView.swift */,
@@ -422,11 +431,11 @@
58A8BE8223A0F362006B74AC /* UIAlertController+Error.swift */,
587CBFE222807F530028DED3 /* UIColor+Helpers.swift */,
58CCA0152242560B004F3011 /* UIColor+Palette.swift */,
+ 58FD5BF12424F7D700112C88 /* UserInterfaceInteractionRestriction.swift */,
581CBCE52296B97300727D7F /* ViewControllerIdentifier.swift */,
58B8743122B25A7600015324 /* WireguardAssociatedAddresses.swift */,
5877152F23981F7B001F8237 /* WireguardKeysViewController.swift */,
58C6B35322BB87C4003C19AD /* WireguardPrivateKey.swift */,
- 584E96B9240D791E00D3334F /* CancellableDelayPublisher.swift */,
);
path = MullvadVPN;
sourceTree = "<group>";
@@ -773,6 +782,7 @@
58C6B35E22BBBFE3003C19AD /* Data+HexCoding.swift in Sources */,
58AEEF652344A36000C9BBD5 /* KeychainError.swift in Sources */,
58CCA01222424D11004F3011 /* SettingsViewController.swift in Sources */,
+ 58FD5BF42428C67600112C88 /* InAppPurchaseButton.swift in Sources */,
589AB4F7227B64450039131E /* BasicTableViewCell.swift in Sources */,
5888AD7F2279B6BF0051EB06 /* RelayStatusIndicatorView.swift in Sources */,
5867A51C2248F26A005513C0 /* SegueIdentifier.swift in Sources */,
@@ -784,7 +794,9 @@
588AE72F2362001F009F9F2E /* MutuallyExclusive.swift in Sources */,
5888AD89227B18C40051EB06 /* RelayList.swift in Sources */,
587AD7C623421D7000E93A53 /* TunnelConfiguration.swift in Sources */,
+ 58FD5BF024238EB300112C88 /* SKProduct+Formatting.swift in Sources */,
58561C99239A5D1500BD6B5E /* IPEndpoint.swift in Sources */,
+ 58FD5BF22424F7D700112C88 /* UserInterfaceInteractionRestriction.swift in Sources */,
5811DE50239014550011EB53 /* NEVPNStatus+Debug.swift in Sources */,
58AEEF682344A40800C9BBD5 /* TunnelConfigurationCoder.swift in Sources */,
58C3A4B222456F1B00340BDB /* AccountInputGroupView.swift in Sources */,
diff --git a/ios/MullvadVPN/AccountViewController.swift b/ios/MullvadVPN/AccountViewController.swift
index 58a35875cc..e7ed9b7ef3 100644
--- a/ios/MullvadVPN/AccountViewController.swift
+++ b/ios/MullvadVPN/AccountViewController.swift
@@ -7,34 +7,152 @@
//
import Combine
+import StoreKit
import UIKit
+import os
class AccountViewController: UIViewController {
@IBOutlet var accountTokenButton: UIButton!
+ @IBOutlet var purchaseButton: InAppPurchaseButton!
+ @IBOutlet var restoreButton: AppButton!
+ @IBOutlet var logoutButton: AppButton!
@IBOutlet var expiryLabel: UILabel!
+ @IBOutlet var activityIndicator: SpinnerActivityIndicatorView!
+ private var accountExpirySubscriber: AnyCancellable?
private var logoutSubscriber: AnyCancellable?
private var copyToPasteboardSubscriber: AnyCancellable?
+ private var requestProductsSubscriber: AnyCancellable?
+ private var purchaseSubscriber: AnyCancellable?
+ private var restorePurchasesSubscriber: AnyCancellable?
+
+ private lazy var purchaseButtonInteractionRestriction =
+ UserInterfaceInteractionRestriction(scheduler: DispatchQueue.main) {
+ [weak self] (enableUserInteraction, _) in
+ self?.purchaseButton.isEnabled = enableUserInteraction
+ }
+
+ private lazy var viewControllerInteractionRestriction =
+ UserInterfaceInteractionRestriction(scheduler: DispatchQueue.main) {
+ [weak self] (enableUserInteraction, animated) in
+ self?.setEnableUserInteraction(enableUserInteraction, animated: true)
+ }
+
+ private lazy var compoundInteractionRestriction =
+ CompoundUserInterfaceInteractionRestriction(restrictions: [
+ purchaseButtonInteractionRestriction, viewControllerInteractionRestriction])
+
+ private var product: SKProduct?
+
+ // MARK: - View lifecycle
override func viewDidLoad() {
super.viewDidLoad()
+ accountExpirySubscriber = NotificationCenter.default
+ .publisher(for: Account.didUpdateAccountExpiryNotification, object: Account.shared)
+ .receive(on: DispatchQueue.main)
+ .sink { [weak self] (notification) in
+ guard let newExpiryDate = notification
+ .userInfo?[Account.newAccountExpiryUserInfoKey] as? Date else { return }
+
+ self?.updateAccountExpiry(expiryDate: newExpiryDate)
+ }
+
+ // Make sure the buy button scales down the font size to fit the long labels.
+ // Changing baseline adjustment helps to prevent the text from being misaligned after
+ // being scaled down.
+ purchaseButton.titleLabel?.adjustsFontSizeToFitWidth = true
+ purchaseButton.titleLabel?.baselineAdjustment = .alignCenters
+
accountTokenButton.setTitle(Account.shared.token, for: .normal)
if let expiryDate = Account.shared.expiry {
- let accountExpiry = AccountExpiry(date: expiryDate)
+ updateAccountExpiry(expiryDate: expiryDate)
+ }
+
+ requestStoreProducts()
+ }
+
+ // MARK: - Private methods
- if accountExpiry.isExpired {
- expiryLabel.text = NSLocalizedString("OUT OF TIME", comment: "")
- expiryLabel.textColor = .dangerColor
- } else {
- expiryLabel.text = accountExpiry.formattedDate
- expiryLabel.textColor = .white
- }
+ private func updateAccountExpiry(expiryDate: Date) {
+ let accountExpiry = AccountExpiry(date: expiryDate)
+
+ if accountExpiry.isExpired {
+ expiryLabel.text = NSLocalizedString("OUT OF TIME", comment: "")
+ expiryLabel.textColor = .dangerColor
+ } else {
+ expiryLabel.text = accountExpiry.formattedDate
+ expiryLabel.textColor = .white
}
}
+ private func requestStoreProducts() {
+ purchaseButton.isLoading = true
+
+ requestProductsSubscriber = AppStorePaymentManager.shared.requestProducts(with: [.thirtyDays])
+ .retry(1)
+ .receive(on: DispatchQueue.main)
+ .restrictUserInterfaceInteraction(with: self.purchaseButtonInteractionRestriction, animated: true)
+ .sink(receiveCompletion: { [weak self] (completion) in
+ if case .finished = completion {
+ self?.purchaseButton.isLoading = false
+ }
+ }, receiveValue: { [weak self] (response) in
+ if let product = response.products.first {
+ self?.setProduct(product, animated: true)
+ }
+ })
+ }
+
+ private func setProduct(_ product: SKProduct, animated: Bool) {
+ self.product = product
+
+ let localizedPrice = product.localizedPrice ?? ""
+ let title = String(format: NSLocalizedString("%@ (%@)", comment: ""),
+ product.localizedTitle, localizedPrice)
+ purchaseButton.setTitle(title, for: .normal)
+ }
+
+ private func setEnableUserInteraction(_ enableUserInteraction: Bool, animated: Bool) {
+ // Disable all buttons
+ [restoreButton, logoutButton].forEach { (button) in
+ button?.isEnabled = enableUserInteraction
+ }
+
+ // Disable any interaction within the view
+ view.isUserInteractionEnabled = enableUserInteraction
+
+ // Prevent view controller from being swiped away by user
+ isModalInPresentation = !enableUserInteraction
+
+ // Hide back button in navigation bar
+ navigationItem.setHidesBackButton(!enableUserInteraction, animated: animated)
+
+ // Show/hide the spinner next to "Paid until"
+ if enableUserInteraction {
+ activityIndicator.stopAnimating()
+ } else {
+ activityIndicator.startAnimating()
+ }
+ }
+
+ private func showTimeAddedConfirmationAlert(
+ with response: SendAppStoreReceiptResponse,
+ context: SendAppStoreReceiptResponse.Context)
+ {
+ let alertController = UIAlertController(
+ title: response.alertTitle(context: context),
+ message: response.alertMessage(context: context),
+ preferredStyle: .alert
+ )
+ alertController.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: ""), style: .cancel))
+
+ present(alertController, animated: true)
+ }
+
// MARK: - Actions
@IBAction func doLogout() {
@@ -79,4 +197,71 @@ class AccountViewController: UIViewController {
})
}
+ @IBAction func doPurchase() {
+ guard let product = product else { return }
+
+ let payment = SKPayment(product: product)
+
+ purchaseSubscriber = AppStorePaymentManager.shared
+ .addPayment(payment, for: Account.shared.token!)
+ .receive(on: DispatchQueue.main)
+ .restrictUserInterfaceInteraction(with: compoundInteractionRestriction, animated: true)
+ .sink(receiveCompletion: { [weak self] (completion) in
+ if case .failure(let error) = completion {
+ self?.presentError(error, preferredStyle: .alert)
+ }
+ }, receiveValue: { [weak self] (response) in
+ self?.showTimeAddedConfirmationAlert(with: response, context: .purchase)
+ })
+ }
+
+ @IBAction func restorePurchases() {
+ restorePurchasesSubscriber = AppStorePaymentManager.shared
+ .restorePurchases(for: Account.shared.token!)
+ .receive(on: DispatchQueue.main)
+ .restrictUserInterfaceInteraction(with: compoundInteractionRestriction, animated: true)
+ .sink(receiveCompletion: { [weak self] (completion) in
+ if case .failure(let error) = completion {
+ self?.presentError(error, preferredStyle: .alert)
+ }
+ }, receiveValue: { [weak self] (response) in
+ self?.showTimeAddedConfirmationAlert(with: response, context: .restoration)
+ })
+ }
+
+}
+
+private extension SendAppStoreReceiptResponse {
+
+ enum Context {
+ case purchase
+ case restoration
+ }
+
+ func alertTitle(context: Context) -> String {
+ switch context {
+ case .purchase:
+ return NSLocalizedString("Thanks for your purchase", comment: "")
+ case .restoration:
+ return NSLocalizedString("Restore purchases", comment: "")
+ }
+ }
+
+ func alertMessage(context: Context) -> String {
+ switch context {
+ case .purchase:
+ return String(
+ format: NSLocalizedString("%@ have been added to your account", comment: ""),
+ formattedTimeAdded ?? ""
+ )
+ case .restoration:
+ return timeAdded.isZero
+ ? NSLocalizedString(
+ "Your previous purchases have already been added to this account.",
+ comment: "")
+ : String(
+ format: NSLocalizedString("%@ have been added to your account", comment: ""),
+ formattedTimeAdded ?? "")
+ }
+ }
}
diff --git a/ios/MullvadVPN/Base.lproj/Main.storyboard b/ios/MullvadVPN/Base.lproj/Main.storyboard
index ce028e4a78..45f4feed30 100644
--- a/ios/MullvadVPN/Base.lproj/Main.storyboard
+++ b/ios/MullvadVPN/Base.lproj/Main.storyboard
@@ -40,10 +40,10 @@
<rect key="frame" x="0.0" y="0.0" width="375" height="93"/>
<subviews>
<imageView userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="LogoIcon" translatesAutoresizingMaskIntoConstraints="NO" id="cKg-hE-JsS">
- <rect key="frame" x="12" y="31.5" width="49.000000000000014" height="50"/>
+ <rect key="frame" x="12" y="6.5" width="98" height="100"/>
</imageView>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="uXv-Tf-PET">
- <rect key="frame" x="335" y="44.5" width="24" height="24"/>
+ <rect key="frame" x="311" y="32.5" width="48" height="48"/>
<accessibility key="accessibilityConfiguration" identifier="SettingsButton"/>
<state key="normal" image="IconSettings"/>
<connections>
@@ -51,7 +51,7 @@
</connections>
</button>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="MULLVAD VPN" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="dqy-A0-TdV">
- <rect key="frame" x="69" y="42" width="168" height="29"/>
+ <rect key="frame" x="118" y="42" width="168" height="29"/>
<fontDescription key="fontDescription" type="boldSystem" pointSize="24"/>
<color key="textColor" white="1" alpha="0.80000000000000004" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
@@ -113,7 +113,7 @@
</constraints>
</view>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" alpha="0.0" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="IconSuccess" translatesAutoresizingMaskIntoConstraints="NO" id="7ux-Tb-Fzq">
- <rect key="frame" x="157.5" y="167" width="60" height="60"/>
+ <rect key="frame" x="127.5" y="137" width="120" height="120"/>
</imageView>
<view contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" translatesAutoresizingMaskIntoConstraints="NO" id="V3j-Lb-fSQ" userLabel="Form">
<rect key="frame" x="0.0" y="251" width="375" height="125.5"/>
@@ -374,13 +374,13 @@
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Account" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Lve-Kd-qTr">
- <rect key="frame" x="16" y="11" width="63.5" height="21.5"/>
+ <rect key="frame" x="16" y="11" width="63.5" height="21"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="252" verticalHuggingPriority="251" text="A YEAR LEFT" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="QeD-EQ-Ruo">
- <rect key="frame" x="259" y="11" width="81" height="21.5"/>
+ <rect key="frame" x="259" y="11" width="81" height="21"/>
<fontDescription key="fontDescription" type="system" weight="medium" pointSize="13"/>
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
@@ -412,13 +412,13 @@
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="App version" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="pYC-Zb-8N9">
- <rect key="frame" x="16" y="11" width="91" height="21.5"/>
+ <rect key="frame" x="16" y="11" width="91" height="21"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="252" verticalHuggingPriority="251" text="2018.3" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="sOr-vj-cg7">
- <rect key="frame" x="316.5" y="11" width="42.5" height="21.5"/>
+ <rect key="frame" x="316.5" y="11" width="42.5" height="21"/>
<fontDescription key="fontDescription" type="system" weight="medium" pointSize="13"/>
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
@@ -447,7 +447,7 @@
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Amw-A3-ePS">
- <rect key="frame" x="16" y="11" width="324" height="21.5"/>
+ <rect key="frame" x="16" y="11" width="324" height="21"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
@@ -741,10 +741,10 @@
<rect key="frame" x="0.0" y="0.0" width="375" height="647"/>
<subviews>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="rkG-Xa-pEO" userLabel="Container">
- <rect key="frame" x="0.0" y="0.0" width="375" height="229.5"/>
+ <rect key="frame" x="0.0" y="0.0" width="375" height="349.5"/>
<subviews>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="nkx-Eb-7le" userLabel="Content">
- <rect key="frame" x="24" y="24" width="327" height="181.5"/>
+ <rect key="frame" x="24" y="24" width="327" height="301.5"/>
<subviews>
<view contentMode="scaleToFill" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="HzF-8Z-UBs" userLabel="Account number">
<rect key="frame" x="0.0" y="0.0" width="327" height="46"/>
@@ -785,12 +785,40 @@
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="NMg-f0-BTW">
<rect key="frame" x="0.0" y="0.0" width="327" height="45.5"/>
<subviews>
- <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Active until" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="nrG-9Q-lWI">
+ <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="fyk-lv-ggt">
<rect key="frame" x="0.0" y="0.0" width="327" height="17"/>
- <fontDescription key="fontDescription" type="system" pointSize="14"/>
- <color key="textColor" white="1" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
- <nil key="highlightedColor"/>
- </label>
+ <subviews>
+ <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="751" text="Paid until" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="nrG-9Q-lWI">
+ <rect key="frame" x="0.0" y="0.0" width="59.5" height="17"/>
+ <fontDescription key="fontDescription" type="system" pointSize="14"/>
+ <color key="textColor" white="1" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
+ <nil key="highlightedColor"/>
+ </label>
+ <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="20K-WW-5v6" customClass="SpinnerActivityIndicatorView" customModule="MullvadVPN" customModuleProvider="target">
+ <rect key="frame" x="311" y="0.5" width="16" height="16"/>
+ <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
+ <color key="tintColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
+ <constraints>
+ <constraint firstAttribute="width" constant="16" id="Hym-zs-PN7"/>
+ <constraint firstAttribute="height" constant="16" id="uys-o5-CZJ"/>
+ </constraints>
+ <userDefinedRuntimeAttributes>
+ <userDefinedRuntimeAttribute type="number" keyPath="thickness">
+ <real key="value" value="2"/>
+ </userDefinedRuntimeAttribute>
+ </userDefinedRuntimeAttributes>
+ </view>
+ </subviews>
+ <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
+ <constraints>
+ <constraint firstItem="20K-WW-5v6" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="nrG-9Q-lWI" secondAttribute="trailing" constant="8" symbolic="YES" id="3uR-Ln-ICn"/>
+ <constraint firstItem="nrG-9Q-lWI" firstAttribute="top" secondItem="fyk-lv-ggt" secondAttribute="top" id="a1L-QD-n7C"/>
+ <constraint firstAttribute="bottom" secondItem="nrG-9Q-lWI" secondAttribute="bottom" id="cbS-5z-9b7"/>
+ <constraint firstItem="nrG-9Q-lWI" firstAttribute="leading" secondItem="fyk-lv-ggt" secondAttribute="leading" id="ii0-xx-bNC"/>
+ <constraint firstAttribute="trailing" secondItem="20K-WW-5v6" secondAttribute="trailing" id="wwh-w9-ngZ"/>
+ <constraint firstItem="20K-WW-5v6" firstAttribute="centerY" secondItem="fyk-lv-ggt" secondAttribute="centerY" id="z8g-E3-YGZ"/>
+ </constraints>
+ </view>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" text="May 16, 2019" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsLetterSpacingToFitWidth="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="8Vg-dd-ZpW">
<rect key="frame" x="0.0" y="25" width="327" height="20.5"/>
<fontDescription key="fontDescription" type="system" weight="medium" pointSize="17"/>
@@ -807,8 +835,39 @@
<constraint firstItem="NMg-f0-BTW" firstAttribute="leading" secondItem="459-0n-9V2" secondAttribute="leading" id="vqI-Vt-8V6"/>
</constraints>
</view>
+ <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="12" translatesAutoresizingMaskIntoConstraints="NO" id="J7Z-sf-Cjx">
+ <rect key="frame" x="0.0" y="139.5" width="327" height="96"/>
+ <subviews>
+ <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="Ja8-Zt-rQX" customClass="InAppPurchaseButton" customModule="MullvadVPN" customModuleProvider="target">
+ <rect key="frame" x="0.0" y="0.0" width="327" height="42"/>
+ <accessibility key="accessibilityConfiguration" identifier="LogoutButton"/>
+ <constraints>
+ <constraint firstAttribute="height" constant="42" placeholder="YES" id="h63-ia-ihB"/>
+ </constraints>
+ <state key="normal" title="Display name for in-app purchase" backgroundImage="SuccessButton">
+ <color key="titleColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
+ </state>
+ <connections>
+ <action selector="doPurchase" destination="ruh-Q2-P39" eventType="touchUpInside" id="PHS-Qd-y9J"/>
+ </connections>
+ </button>
+ <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="h5f-yH-jeE" customClass="AppButton" customModule="MullvadVPN" customModuleProvider="target">
+ <rect key="frame" x="0.0" y="54" width="327" height="42"/>
+ <accessibility key="accessibilityConfiguration" identifier="LogoutButton"/>
+ <constraints>
+ <constraint firstAttribute="height" constant="42" placeholder="YES" id="Zuv-DV-LSL"/>
+ </constraints>
+ <state key="normal" title="Restore purchases" backgroundImage="DefaultButton">
+ <color key="titleColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
+ </state>
+ <connections>
+ <action selector="restorePurchases" destination="ruh-Q2-P39" eventType="touchUpInside" id="ILp-fL-Ab5"/>
+ </connections>
+ </button>
+ </subviews>
+ </stackView>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="QHr-Lz-v6t" customClass="AppButton" customModule="MullvadVPN" customModuleProvider="target">
- <rect key="frame" x="0.0" y="139.5" width="327" height="42"/>
+ <rect key="frame" x="0.0" y="259.5" width="327" height="42"/>
<accessibility key="accessibilityConfiguration" identifier="LogoutButton"/>
<constraints>
<constraint firstAttribute="height" constant="42" placeholder="YES" id="VYx-GQ-CIz"/>
@@ -822,15 +881,18 @@
</button>
</subviews>
<constraints>
- <constraint firstItem="QHr-Lz-v6t" firstAttribute="top" secondItem="459-0n-9V2" secondAttribute="bottom" constant="24" id="6IV-09-erh"/>
<constraint firstItem="QHr-Lz-v6t" firstAttribute="leading" secondItem="nkx-Eb-7le" secondAttribute="leading" id="EEA-bt-bSx"/>
<constraint firstItem="459-0n-9V2" firstAttribute="leading" secondItem="nkx-Eb-7le" secondAttribute="leading" id="G86-ck-dqe"/>
<constraint firstAttribute="trailing" secondItem="459-0n-9V2" secondAttribute="trailing" id="HUb-T5-Wkk"/>
+ <constraint firstItem="J7Z-sf-Cjx" firstAttribute="top" secondItem="459-0n-9V2" secondAttribute="bottom" constant="24" id="LFm-ye-Fog"/>
<constraint firstItem="459-0n-9V2" firstAttribute="top" secondItem="HzF-8Z-UBs" secondAttribute="bottom" constant="24" id="Ttn-aK-Cj0"/>
+ <constraint firstItem="QHr-Lz-v6t" firstAttribute="top" secondItem="J7Z-sf-Cjx" secondAttribute="bottom" constant="24" id="Zfk-OU-5Ka"/>
<constraint firstItem="HzF-8Z-UBs" firstAttribute="leading" secondItem="nkx-Eb-7le" secondAttribute="leading" id="bCL-Z9-nk4"/>
<constraint firstAttribute="trailing" secondItem="QHr-Lz-v6t" secondAttribute="trailing" id="eBz-Is-dHp"/>
<constraint firstAttribute="bottom" secondItem="QHr-Lz-v6t" secondAttribute="bottom" id="fRA-bC-3eO"/>
+ <constraint firstItem="J7Z-sf-Cjx" firstAttribute="leading" secondItem="nkx-Eb-7le" secondAttribute="leading" id="nNI-7C-xEi"/>
<constraint firstAttribute="trailing" secondItem="HzF-8Z-UBs" secondAttribute="trailing" id="pVC-Ci-c98"/>
+ <constraint firstAttribute="trailing" secondItem="J7Z-sf-Cjx" secondAttribute="trailing" id="tgl-YH-hLZ"/>
<constraint firstItem="HzF-8Z-UBs" firstAttribute="top" secondItem="nkx-Eb-7le" secondAttribute="top" id="vsH-Ee-fch"/>
</constraints>
</view>
@@ -864,14 +926,18 @@
<navigationItem key="navigationItem" title="Account" id="rL3-Y8-3g8"/>
<connections>
<outlet property="accountTokenButton" destination="XNH-JJ-9gR" id="yCU-t3-ayW"/>
+ <outlet property="activityIndicator" destination="20K-WW-5v6" id="DKS-1x-8oF"/>
<outlet property="expiryLabel" destination="8Vg-dd-ZpW" id="3n5-2Z-J8y"/>
+ <outlet property="logoutButton" destination="QHr-Lz-v6t" id="K7y-9z-xdj"/>
+ <outlet property="purchaseButton" destination="Ja8-Zt-rQX" id="fbk-aY-fj5"/>
+ <outlet property="restoreButton" destination="h5f-yH-jeE" id="jAy-mR-DEN"/>
<segue destination="P2i-eG-jQx" kind="unwind" identifier="Logout" unwindAction="unwindFromAccountWithSegue:" id="5li-wk-yRM"/>
</connections>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="3tt-67-nI8" userLabel="First Responder" sceneMemberID="firstResponder"/>
<exit id="P2i-eG-jQx" userLabel="Exit" sceneMemberID="exit"/>
</objects>
- <point key="canvasLocation" x="2578" y="-1258"/>
+ <point key="canvasLocation" x="2576.8000000000002" y="-1258.0209895052474"/>
</scene>
<!--Navigation Controller-->
<scene sceneID="er3-W2-NkS">
@@ -903,7 +969,7 @@
<rect key="frame" x="0.0" y="0.0" width="375" height="598"/>
<subviews>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="N9k-cQ-tlw" userLabel="Content view">
- <rect key="frame" x="0.0" y="0.0" width="375" height="558"/>
+ <rect key="frame" x="0.0" y="0.0" width="375" height="568"/>
<subviews>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="Wnl-L9-JqG" userLabel="Logo header">
<rect key="frame" x="0.0" y="0.0" width="375" height="100"/>
@@ -939,7 +1005,7 @@
<nil key="highlightedColor"/>
</label>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="leading" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="Cas-Tk-gcz" customClass="LinkButton" customModule="MullvadVPN" customModuleProvider="target">
- <rect key="frame" x="20" y="516" width="20" height="22"/>
+ <rect key="frame" x="20" y="516" width="36" height="32"/>
<fontDescription key="fontDescription" name=".AppleSystemUIFont" family=".AppleSystemUIFont" pointSize="18"/>
<color key="tintColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<state key="normal" title="Privacy Policy" image="IconExtlink"/>
@@ -1067,31 +1133,31 @@
</view>
<prototypes>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" reuseIdentifier="Cell" id="aFz-H5-sPu" customClass="SelectLocationCell" customModule="MullvadVPN" customModuleProvider="target">
- <rect key="frame" x="0.0" y="173" width="375" height="43"/>
+ <rect key="frame" x="0.0" y="173" width="375" height="48.5"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="aFz-H5-sPu" id="6nQ-gT-vzf">
- <rect key="frame" x="0.0" y="0.0" width="375" height="43"/>
+ <rect key="frame" x="0.0" y="0.0" width="375" height="48.5"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="5ag-N4-pUg" customClass="RelayStatusIndicatorView" customModule="MullvadVPN" customModuleProvider="target">
- <rect key="frame" x="16" y="13.5" width="16" height="16"/>
+ <rect key="frame" x="16" y="16.5" width="16" height="16"/>
<constraints>
<constraint firstAttribute="height" constant="16" id="QWj-hh-I3P"/>
<constraint firstAttribute="width" constant="16" id="TFV-yi-LXG"/>
</constraints>
</view>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="y7o-0b-MUV">
- <rect key="frame" x="44" y="11" width="42" height="21"/>
+ <rect key="frame" x="44" y="11" width="42" height="26.5"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="IconTick" translatesAutoresizingMaskIntoConstraints="NO" id="e1o-Bl-zd5">
- <rect key="frame" x="12" y="9.5" width="24" height="24"/>
+ <rect key="frame" x="0.0" y="0.5" width="48" height="48"/>
<color key="tintColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</imageView>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="KaW-bN-I51">
- <rect key="frame" x="311" y="0.0" width="64" height="43"/>
+ <rect key="frame" x="311" y="0.0" width="64" height="48.5"/>
<constraints>
<constraint firstAttribute="width" constant="64" id="UU3-Di-65E"/>
</constraints>
diff --git a/ios/MullvadVPN/InAppPurchaseButton.swift b/ios/MullvadVPN/InAppPurchaseButton.swift
new file mode 100644
index 0000000000..38ae3c90fe
--- /dev/null
+++ b/ios/MullvadVPN/InAppPurchaseButton.swift
@@ -0,0 +1,47 @@
+//
+// InAppPurchaseButton.swift
+// MullvadVPN
+//
+// Created by pronebird on 23/03/2020.
+// Copyright © 2020 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+import UIKit
+
+class InAppPurchaseButton: AppButton {
+
+ let activityIndicator = SpinnerActivityIndicatorView(style: .medium)
+
+ var isLoading: Bool = false {
+ didSet {
+ if isLoading {
+ activityIndicator.startAnimating()
+ } else {
+ activityIndicator.stopAnimating()
+ }
+
+ titleLabel?.alpha = isLoading ? 0 : 1
+ }
+ }
+
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+ commonInit()
+ }
+
+ required init?(coder aDecoder: NSCoder) {
+ super.init(coder: aDecoder)
+ commonInit()
+ }
+
+ private func commonInit() {
+ addSubview(activityIndicator)
+ }
+
+ override func layoutSubviews() {
+ super.layoutSubviews()
+
+ activityIndicator.center = self.center
+ }
+}
diff --git a/ios/MullvadVPN/JsonRpc.swift b/ios/MullvadVPN/JsonRpc.swift
index 2e79cf7e90..a41678fa1e 100644
--- a/ios/MullvadVPN/JsonRpc.swift
+++ b/ios/MullvadVPN/JsonRpc.swift
@@ -14,7 +14,7 @@ extension Encodable {
}
}
-struct AnyEncodable : Encodable {
+struct AnyEncodable: Encodable {
let value: Encodable
init(_ value: Encodable) {
@@ -44,7 +44,7 @@ class JsonRpcResponseError<ResponseCode>: Error, Decodable
let code: ResponseCode
let message: String
- var localizedDescription: String? {
+ var localizedDescription: String {
return message
}
diff --git a/ios/MullvadVPN/SKProduct+Formatting.swift b/ios/MullvadVPN/SKProduct+Formatting.swift
new file mode 100644
index 0000000000..19063622bc
--- /dev/null
+++ b/ios/MullvadVPN/SKProduct+Formatting.swift
@@ -0,0 +1,22 @@
+//
+// SKProduct+Formatting.swift
+// MullvadVPN
+//
+// Created by pronebird on 19/03/2020.
+// Copyright © 2020 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+import StoreKit
+
+extension SKProduct {
+
+ var localizedPrice: String? {
+ let formatter = NumberFormatter()
+ formatter.locale = priceLocale
+ formatter.numberStyle = .currency
+
+ return formatter.string(from: price)
+ }
+
+}
diff --git a/ios/MullvadVPN/UserInterfaceInteractionRestriction.swift b/ios/MullvadVPN/UserInterfaceInteractionRestriction.swift
new file mode 100644
index 0000000000..a8f1455b5f
--- /dev/null
+++ b/ios/MullvadVPN/UserInterfaceInteractionRestriction.swift
@@ -0,0 +1,90 @@
+//
+// UserInterfaceInteractionRestriction.swift
+// MullvadVPN
+//
+// Created by pronebird on 20/03/2020.
+// Copyright © 2020 Mullvad VPN AB. All rights reserved.
+//
+
+import Combine
+import Foundation
+
+/// A protocol describing a common interface for the implementations of user interaction restriction
+protocol UserInterfaceInteractionRestrictionProtocol {
+ /// Raise the user interface interaction restrictions
+ func lift(animated: Bool)
+
+ /// Lift the user interface interaction restrictions
+ func raise(animated: Bool)
+}
+
+/// A counter based user interface interaction restriction implementation
+class UserInterfaceInteractionRestriction<S: Scheduler>
+ : UserInterfaceInteractionRestrictionProtocol
+{
+ typealias Action = (_ disableUserInteraction: Bool, _ animated: Bool) -> Void
+
+ private let action: Action
+ private let scheduler: S
+ private var counter: UInt = 0
+
+ init(scheduler: S, action: @escaping Action) {
+ self.action = action
+ self.scheduler = scheduler
+ }
+
+ func raise(animated: Bool) {
+ scheduler.schedule {
+ if self.counter == 0 {
+ self.action(false, animated)
+ }
+ self.counter += 1
+ }
+ }
+
+ func lift(animated: Bool) {
+ scheduler.schedule {
+ guard self.counter > 0 else { return }
+
+ self.counter -= 1
+ if self.counter == 0 {
+ self.action(true, animated)
+ }
+ }
+ }
+}
+
+/// A user interface restriction implementation that simply combines multiple child restrictions
+/// into one and automatically forwards all calls to them in the order in which they are given to
+/// the initializer.
+class CompoundUserInterfaceInteractionRestriction: UserInterfaceInteractionRestrictionProtocol {
+ private let restrictions: [UserInterfaceInteractionRestrictionProtocol]
+
+ init(restrictions: [UserInterfaceInteractionRestrictionProtocol]) {
+ self.restrictions = restrictions
+ }
+
+ func lift(animated: Bool) {
+ restrictions.forEach { $0.lift(animated: animated) }
+ }
+
+ func raise(animated: Bool) {
+ restrictions.forEach { $0.raise(animated: animated) }
+ }
+}
+
+extension Publisher {
+ func restrictUserInterfaceInteraction(
+ with restriction: UserInterfaceInteractionRestrictionProtocol,
+ animated: Bool
+ ) -> Publishers.HandleEvents<Self>
+ {
+ return handleEvents(receiveSubscription: { _ in
+ restriction.raise(animated: animated)
+ }, receiveCompletion: { _ in
+ restriction.lift(animated: animated)
+ }, receiveCancel: { () in
+ restriction.lift(animated: animated)
+ })
+ }
+}