From e62aae16fc9971349655deb6487f64ca15ed0ce2 Mon Sep 17 00:00:00 2001 From: Davide De Rosa Date: Sun, 27 Oct 2019 01:24:32 +0200 Subject: [PATCH 1/9] Add new in-app purchases - Rename Donation to Product accordingly - Infer product from provider name --- Passepartout-iOS/Global/Donation.swift | 49 -------- Passepartout-iOS/Global/Product.swift | 109 ++++++++++++++++++ Passepartout-iOS/Global/ProductManager.swift | 4 +- .../Organizer/DonationViewController.swift | 9 +- Passepartout.xcodeproj/project.pbxproj | 8 +- 5 files changed, 117 insertions(+), 62 deletions(-) delete mode 100644 Passepartout-iOS/Global/Donation.swift create mode 100644 Passepartout-iOS/Global/Product.swift diff --git a/Passepartout-iOS/Global/Donation.swift b/Passepartout-iOS/Global/Donation.swift deleted file mode 100644 index 5eef090e..00000000 --- a/Passepartout-iOS/Global/Donation.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// Donation.swift -// Passepartout-iOS -// -// Created by Davide De Rosa on 10/11/19. -// Copyright (c) 2019 Davide De Rosa. All rights reserved. -// -// https://github.com/passepartoutvpn -// -// This file is part of Passepartout. -// -// Passepartout is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// Passepartout is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with Passepartout. If not, see . -// - -import Foundation - -enum Donation: String { - case tiny = "com.algoritmico.ios.Passepartout.donations.Tiny" - - case small = "com.algoritmico.ios.Passepartout.donations.Small" - - case medium = "com.algoritmico.ios.Passepartout.donations.Medium" - - case big = "com.algoritmico.ios.Passepartout.donations.Big" - - case huge = "com.algoritmico.ios.Passepartout.donations.Huge" - - case maxi = "com.algoritmico.ios.Passepartout.donations.Maxi" - - static let all: [Donation] = [ - .tiny, - .small, - .medium, - .big, - .huge, - .maxi - ] -} diff --git a/Passepartout-iOS/Global/Product.swift b/Passepartout-iOS/Global/Product.swift new file mode 100644 index 00000000..f3ff41cb --- /dev/null +++ b/Passepartout-iOS/Global/Product.swift @@ -0,0 +1,109 @@ +// +// Product.swift +// Passepartout-iOS +// +// Created by Davide De Rosa on 10/11/19. +// Copyright (c) 2019 Davide De Rosa. All rights reserved. +// +// https://github.com/passepartoutvpn +// +// This file is part of Passepartout. +// +// Passepartout is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Passepartout is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Passepartout. If not, see . +// + +import Foundation +import PassepartoutCore + +enum Product: String { + + // MARK: Donations + + case tinyDonation = "com.algoritmico.ios.Passepartout.donations.Tiny" + + case smallDonation = "com.algoritmico.ios.Passepartout.donations.Small" + + case mediumDonation = "com.algoritmico.ios.Passepartout.donations.Medium" + + case bigDonation = "com.algoritmico.ios.Passepartout.donations.Big" + + case hugeDonation = "com.algoritmico.ios.Passepartout.donations.Huge" + + case maxiDonation = "com.algoritmico.ios.Passepartout.donations.Maxi" + + static let allDonations: [Product] = [ + .tinyDonation, + .smallDonation, + .mediumDonation, + .bigDonation, + .hugeDonation, + .maxiDonation + ] + + // MARK: Features + + case unlimitedHosts = "com.algoritmico.ios.Passepartout.features.unlimited_hosts" + + case trustedNetworks = "com.algoritmico.ios.Passepartout.features.trusted_networks" + + case siriShortcuts = "com.algoritmico.ios.Passepartout.features.siri" + + case fullVersion = "com.algoritmico.ios.Passepartout.features.full_version" + + static let allFeatures: [Product] = [ + .unlimitedHosts, + .trustedNetworks, + .siriShortcuts, + .fullVersion + ] + + // MARK: Providers + + case mullvad = "com.algoritmico.ios.Passepartout.providers.Mullvad" + + case nordVPN = "com.algoritmico.ios.Passepartout.providers.NordVPN" + + case pia = "com.algoritmico.ios.Passepartout.providers.PIA" + + case protonVPN = "com.algoritmico.ios.Passepartout.providers.ProtonVPN" + + case tunnelBear = "com.algoritmico.ios.Passepartout.providers.TunnelBear" + + case vyprVPN = "com.algoritmico.ios.Passepartout.providers.VyprVPN" + + case windscribe = "com.algoritmico.ios.Passepartout.providers.Windscribe" + + static let allProviders: [Product] = [ + .mullvad, + .nordVPN, + .pia, + .protonVPN, + .tunnelBear, + .vyprVPN, + .windscribe + ] + + // MARK: All + + static let all: [Product] = allDonations + allFeatures + allProviders +} + +extension Infrastructure.Name { + var product: Product { + guard let product = Product(rawValue: "com.algoritmico.ios.Passepartout.providers.\(rawValue)") else { + fatalError("Product not found for provider \(rawValue)") + } + return product + } +} diff --git a/Passepartout-iOS/Global/ProductManager.swift b/Passepartout-iOS/Global/ProductManager.swift index d836ef3a..b2a93a1b 100644 --- a/Passepartout-iOS/Global/ProductManager.swift +++ b/Passepartout-iOS/Global/ProductManager.swift @@ -30,7 +30,7 @@ import Convenience struct ProductManager { static let shared = ProductManager() - private let inApp: InApp + private let inApp: InApp private init() { inApp = InApp() @@ -41,7 +41,7 @@ struct ProductManager { completionHandler?(inApp.products) return } - inApp.requestProducts(withIdentifiers: Donation.all) { _ in + inApp.requestProducts(withIdentifiers: Product.all) { _ in completionHandler?(self.inApp.products) } } diff --git a/Passepartout-iOS/Scenes/Organizer/DonationViewController.swift b/Passepartout-iOS/Scenes/Organizer/DonationViewController.swift index 015d3242..0cdb8359 100644 --- a/Passepartout-iOS/Scenes/Organizer/DonationViewController.swift +++ b/Passepartout-iOS/Scenes/Organizer/DonationViewController.swift @@ -29,7 +29,7 @@ import PassepartoutCore import Convenience class DonationViewController: UITableViewController, StrongTableHost { - private var donationList: [Donation] = [] + private var donationList: [Product] = [] private var productsByIdentifier: [String: SKProduct] = [:] @@ -62,12 +62,7 @@ class DonationViewController: UITableViewController, StrongTableHost { return } - for row in Donation.all { - guard let _ = productsByIdentifier[row.rawValue] else { - continue - } - donationList.append(row) - } + donationList.append(contentsOf: Product.allDonations.filter { productsByIdentifier[$0.rawValue] != nil }) model.set(.donation, count: donationList.count, forSection: .oneTime) if isPurchasing { diff --git a/Passepartout.xcodeproj/project.pbxproj b/Passepartout.xcodeproj/project.pbxproj index 39d6ab50..4a7d31b2 100644 --- a/Passepartout.xcodeproj/project.pbxproj +++ b/Passepartout.xcodeproj/project.pbxproj @@ -57,7 +57,7 @@ 0E3152DB223FA05800F61841 /* ProfileKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E79D14021919F5600BB5FB2 /* ProfileKey.swift */; }; 0E3152DC223FA05800F61841 /* ProviderConnectionProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EBE3AA4213DC1B000BFA2F5 /* ProviderConnectionProfile.swift */; }; 0E3262D9235EE8DA00B5E470 /* HostImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E3262D8235EE8DA00B5E470 /* HostImporter.swift */; }; - 0E3419AD2350815E00419E18 /* Donation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E3419AC2350815E00419E18 /* Donation.swift */; }; + 0E3419AD2350815E00419E18 /* Product.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E3419AC2350815E00419E18 /* Product.swift */; }; 0E3586FE225BD34800509A4D /* ActivityTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E3586FD225BD34800509A4D /* ActivityTableViewCell.swift */; }; 0E36D24D2240234B006AF062 /* ShortcutsAddViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E36D24C2240234B006AF062 /* ShortcutsAddViewController.swift */; }; 0E36D25822403469006AF062 /* Shortcuts.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 0E36D25A22403469006AF062 /* Shortcuts.storyboard */; }; @@ -180,7 +180,7 @@ 0E31529D223F9EF500F61841 /* PassepartoutCore.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = PassepartoutCore.h; sourceTree = ""; }; 0E31529E223F9EF500F61841 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 0E3262D8235EE8DA00B5E470 /* HostImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostImporter.swift; sourceTree = ""; }; - 0E3419AC2350815E00419E18 /* Donation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Donation.swift; sourceTree = ""; }; + 0E3419AC2350815E00419E18 /* Product.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Product.swift; sourceTree = ""; }; 0E3586FD225BD34800509A4D /* ActivityTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityTableViewCell.swift; sourceTree = ""; }; 0E36D24C2240234B006AF062 /* ShortcutsAddViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutsAddViewController.swift; sourceTree = ""; }; 0E36D25B224034AD006AF062 /* ShortcutsConnectToViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShortcutsConnectToViewController.swift; sourceTree = ""; }; @@ -533,10 +533,10 @@ isa = PBXGroup; children = ( 0E45E6E222BD793800F19312 /* App.strings */, - 0E3419AC2350815E00419E18 /* Donation.swift */, 0E3262D8235EE8DA00B5E470 /* HostImporter.swift */, 0EFD943D215BE10800529B64 /* IssueReporter.swift */, 0E4FD7F020D58618002221FF /* Macros.swift */, + 0E3419AC2350815E00419E18 /* Product.swift */, 0E24273F225951B00064A1A3 /* ProductManager.swift */, 0ECC60DD2256B6890020BEAC /* SwiftGen+Assets.swift */, 0EDE8DE320C89028004C739C /* SwiftGen+Scenes.swift */, @@ -984,7 +984,7 @@ 0EF56BBB2185AC8500B0C8AB /* SwiftGen+Segues.swift in Sources */, 0E05C5D620D1645F006EE732 /* SwiftGen+Scenes.swift in Sources */, 0E773BF8224BF37600CDDC8E /* ShortcutsViewController.swift in Sources */, - 0E3419AD2350815E00419E18 /* Donation.swift in Sources */, + 0E3419AD2350815E00419E18 /* Product.swift in Sources */, 0E9CDB6723604AD5006733B4 /* ServerNetworkViewController.swift in Sources */, 0E3262D9235EE8DA00B5E470 /* HostImporter.swift in Sources */, 0EFD9440215BED8E00529B64 /* LabelViewController.swift in Sources */, From 026a94065c4b001f3d57c1b6e049eeb4cfe2bd25 Mon Sep 17 00:00:00 2001 From: Davide De Rosa Date: Sun, 27 Oct 2019 01:31:30 +0200 Subject: [PATCH 2/9] Read features from app store receipt - Use Kvitto to parse App Store receipt - Infer feature/provider eligibility from features - Assume full version in beta - Read receipt even if no products were purchased --- Passepartout-iOS/Global/ProductManager.swift | 94 +++++++++++++++++++- Podfile | 1 + Podfile.lock | 17 +++- 3 files changed, 107 insertions(+), 5 deletions(-) diff --git a/Passepartout-iOS/Global/ProductManager.swift b/Passepartout-iOS/Global/ProductManager.swift index b2a93a1b..5e4dd5ca 100644 --- a/Passepartout-iOS/Global/ProductManager.swift +++ b/Passepartout-iOS/Global/ProductManager.swift @@ -26,14 +26,33 @@ import Foundation import StoreKit import Convenience +import SwiftyBeaver +import Kvitto +import PassepartoutCore + +private let log = SwiftyBeaver.self + +class ProductManager: NSObject { + private static let lastFullVersionNumber = "1.8.1" + + private static let lastFullVersionBuild = "2016" -struct ProductManager { static let shared = ProductManager() private let inApp: InApp - private init() { + private var purchasedAppVersion: String? + + private var purchasedFeatures: Set + + private override init() { inApp = InApp() + purchasedAppVersion = nil + purchasedFeatures = [] + + super.init() + + reloadReceipt() } func listProducts(completionHandler: (([SKProduct]) -> Void)?) { @@ -47,6 +66,75 @@ struct ProductManager { } func purchase(_ product: SKProduct, completionHandler: @escaping (InAppPurchaseResult, Error?) -> Void) { - inApp.purchase(product: product, completionHandler: completionHandler) + inApp.purchase(product: product) { + if $0 == .success { + self.reloadReceipt() + } + completionHandler($0, $1) + } + } + + // MARK: In-app eligibility + + private func reloadReceipt() { + guard let url = Bundle.main.appStoreReceiptURL else { + log.warning("No App Store receipt found!") + return + } + guard let receipt = Receipt(contentsOfURL: url) else { + log.error("Could not parse App Store receipt!") + return + } + + purchasedAppVersion = receipt.originalAppVersion + purchasedFeatures.removeAll() + + if let version = purchasedAppVersion { + log.debug("Original purchased version: \(version)") + + // treat former purchases as full versions + if version <= ProductManager.lastFullVersionNumber { + purchasedFeatures.insert(.fullVersion) + } + } + if let iapReceipts = receipt.inAppPurchaseReceipts { + log.debug("In-app receipts:") + iapReceipts.forEach { + guard let pid = $0.productIdentifier, let date = $0.originalPurchaseDate else { + return + } + log.debug("\t\(pid) [\(date)]") + } + for r in iapReceipts { + guard let pid = r.productIdentifier, let product = Product(rawValue: pid) else { + continue + } + purchasedFeatures.insert(product) + } + } + log.info("Purchased features: \(purchasedFeatures)") + } + + func isFullVersion() -> Bool { + guard !AppConstants.Flags.isBeta else { + return true + } + return purchasedFeatures.contains(.fullVersion) + } + + func isEligible(forFeature feature: Product) -> Bool { + guard !isFullVersion() else { + return true + } + return purchasedFeatures.contains(feature) + } + + func isEligible(forProvider name: Infrastructure.Name) -> Bool { + guard !isFullVersion() else { + return true + } + return purchasedFeatures.contains { + return $0.rawValue.hasSuffix("providers.\(name.rawValue)") + } } } diff --git a/Podfile b/Podfile index f4f6a9af..cfc2b9e7 100644 --- a/Podfile +++ b/Podfile @@ -21,6 +21,7 @@ end target 'PassepartoutCore-iOS' do shared_pods + pod 'Kvitto' end target 'Passepartout-iOS' do diff --git a/Podfile.lock b/Podfile.lock index 27769c28..06e1f205 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -10,6 +10,14 @@ PODS: - Convenience/Persistence (0.0.1) - Convenience/Reviewer (0.0.1) - Convenience/Tables (0.0.1) + - DTFoundation/Core (1.7.14) + - DTFoundation/DTASN1 (1.7.14): + - DTFoundation/Core + - Kvitto (1.0.3): + - DTFoundation/DTASN1 (~> 1.7.13) + - Kvitto/Core (= 1.0.3) + - Kvitto/Core (1.0.3): + - DTFoundation/DTASN1 (~> 1.7.13) - MBProgressHUD (1.1.0) - OpenSSL-Apple (1.1.0l.4) - SSZipArchive (2.2.2) @@ -35,6 +43,7 @@ DEPENDENCIES: - Convenience/Persistence (from `https://github.com/keeshux/convenience`, commit `b990a8c`) - Convenience/Reviewer (from `https://github.com/keeshux/convenience`, commit `b990a8c`) - Convenience/Tables (from `https://github.com/keeshux/convenience`, commit `b990a8c`) + - Kvitto - MBProgressHUD - SSZipArchive - TunnelKit/Extra/LZO (from `https://github.com/passepartoutvpn/tunnelkit`, commit `4d930d3`) @@ -42,6 +51,8 @@ DEPENDENCIES: SPEC REPOS: https://github.com/cocoapods/specs.git: + - DTFoundation + - Kvitto - MBProgressHUD - OpenSSL-Apple - SSZipArchive @@ -64,13 +75,15 @@ CHECKOUT OPTIONS: :git: https://github.com/passepartoutvpn/tunnelkit SPEC CHECKSUMS: - Convenience: c2bc96be4ca77c7018f85b2c63b95c2a44c05c5a + Convenience: c4240c936b2119752ffa0841d40a4bc6a0ba8a5d + DTFoundation: 25aa19bb7c6e225b1dfae195604fb8cf1da0ab4c + Kvitto: d451f893f84ad669850b7cb7d3f8781363e14232 MBProgressHUD: e7baa36a220447d8aeb12769bf0585582f3866d9 OpenSSL-Apple: f3d1668588ea8f06b076dcfa6177cd90452e3800 SSZipArchive: fa16b8cc4cdeceb698e5e5d9f67e9558532fbf23 SwiftyBeaver: aaf2ebd7dac2e952991f46a82ed24ad642867ae2 TunnelKit: 0743f0306be0869d51118ac33e274e7507a93537 -PODFILE CHECKSUM: f45a3fd3744e646a5513e3e25d447d1550c9fefa +PODFILE CHECKSUM: d1e12ff38bc1839dddbbbcaa5e436ea0fad60f70 COCOAPODS: 1.8.4 From f936cffe5eca22553e98e4e6fe46283749865208 Mon Sep 17 00:00:00 2001 From: Davide De Rosa Date: Wed, 30 Oct 2019 11:53:49 +0100 Subject: [PATCH 3/9] Finish up ProductManager implementation - Reload receipt on updated transactions (e.g. promo code) - Implement restore purchases (refresh receipt before restoring) --- Passepartout-iOS/Global/ProductManager.swift | 46 +++++++++++++++++++- Podfile | 2 +- Podfile.lock | 24 +++++----- 3 files changed, 58 insertions(+), 14 deletions(-) diff --git a/Passepartout-iOS/Global/ProductManager.swift b/Passepartout-iOS/Global/ProductManager.swift index 5e4dd5ca..9fb543a2 100644 --- a/Passepartout-iOS/Global/ProductManager.swift +++ b/Passepartout-iOS/Global/ProductManager.swift @@ -43,7 +43,11 @@ class ProductManager: NSObject { private var purchasedAppVersion: String? - private var purchasedFeatures: Set + private(set) var purchasedFeatures: Set + + private var refreshRequest: SKReceiptRefreshRequest? + + private var restoreCompletionHandler: ((Error?) -> Void)? private override init() { inApp = InApp() @@ -53,6 +57,11 @@ class ProductManager: NSObject { super.init() reloadReceipt() + SKPaymentQueue.default().add(self) + } + + deinit { + SKPaymentQueue.default().remove(self) } func listProducts(completionHandler: (([SKProduct]) -> Void)?) { @@ -65,6 +74,10 @@ class ProductManager: NSObject { } } + func product(withIdentifier identifier: Product) -> SKProduct? { + return inApp.product(withIdentifier: identifier) + } + func purchase(_ product: SKProduct, completionHandler: @escaping (InAppPurchaseResult, Error?) -> Void) { inApp.purchase(product: product) { if $0 == .success { @@ -73,6 +86,13 @@ class ProductManager: NSObject { completionHandler($0, $1) } } + + func restorePurchases(completionHandler: @escaping (Error?) -> Void) { + restoreCompletionHandler = completionHandler + refreshRequest = SKReceiptRefreshRequest() + refreshRequest?.delegate = self + refreshRequest?.start() + } // MARK: In-app eligibility @@ -138,3 +158,27 @@ class ProductManager: NSObject { } } } + +extension ProductManager: SKPaymentTransactionObserver { + func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) { + reloadReceipt() + } +} + +extension ProductManager: SKRequestDelegate { + func requestDidFinish(_ request: SKRequest) { + reloadReceipt() + inApp.restorePurchases { [weak self] (finished, _, error) in + guard finished else { + return + } + self?.restoreCompletionHandler?(error) + self?.restoreCompletionHandler = nil + } + } + + func request(_ request: SKRequest, didFailWithError error: Error) { + restoreCompletionHandler?(error) + restoreCompletionHandler = nil + } +} diff --git a/Podfile b/Podfile index cfc2b9e7..94c5667c 100644 --- a/Podfile +++ b/Podfile @@ -14,7 +14,7 @@ def shared_pods pod 'SSZipArchive' for spec in ['About', 'Alerts', 'Dialogs', 'InApp', 'Misc', 'Options', 'Persistence', 'Reviewer', 'Tables'] do - pod "Convenience/#{spec}", :git => 'https://github.com/keeshux/convenience', :commit => 'b990a8c' + pod "Convenience/#{spec}", :git => 'https://github.com/keeshux/convenience', :commit => '22778d5' #pod "Convenience/#{spec}", :path => '../../personal/convenience' end end diff --git a/Podfile.lock b/Podfile.lock index 06e1f205..e5abed72 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -34,15 +34,15 @@ PODS: - TunnelKit/Core DEPENDENCIES: - - Convenience/About (from `https://github.com/keeshux/convenience`, commit `b990a8c`) - - Convenience/Alerts (from `https://github.com/keeshux/convenience`, commit `b990a8c`) - - Convenience/Dialogs (from `https://github.com/keeshux/convenience`, commit `b990a8c`) - - Convenience/InApp (from `https://github.com/keeshux/convenience`, commit `b990a8c`) - - Convenience/Misc (from `https://github.com/keeshux/convenience`, commit `b990a8c`) - - Convenience/Options (from `https://github.com/keeshux/convenience`, commit `b990a8c`) - - Convenience/Persistence (from `https://github.com/keeshux/convenience`, commit `b990a8c`) - - Convenience/Reviewer (from `https://github.com/keeshux/convenience`, commit `b990a8c`) - - Convenience/Tables (from `https://github.com/keeshux/convenience`, commit `b990a8c`) + - Convenience/About (from `https://github.com/keeshux/convenience`, commit `22778d5`) + - Convenience/Alerts (from `https://github.com/keeshux/convenience`, commit `22778d5`) + - Convenience/Dialogs (from `https://github.com/keeshux/convenience`, commit `22778d5`) + - Convenience/InApp (from `https://github.com/keeshux/convenience`, commit `22778d5`) + - Convenience/Misc (from `https://github.com/keeshux/convenience`, commit `22778d5`) + - Convenience/Options (from `https://github.com/keeshux/convenience`, commit `22778d5`) + - Convenience/Persistence (from `https://github.com/keeshux/convenience`, commit `22778d5`) + - Convenience/Reviewer (from `https://github.com/keeshux/convenience`, commit `22778d5`) + - Convenience/Tables (from `https://github.com/keeshux/convenience`, commit `22778d5`) - Kvitto - MBProgressHUD - SSZipArchive @@ -60,7 +60,7 @@ SPEC REPOS: EXTERNAL SOURCES: Convenience: - :commit: b990a8c + :commit: 22778d5 :git: https://github.com/keeshux/convenience TunnelKit: :commit: 4d930d3 @@ -68,7 +68,7 @@ EXTERNAL SOURCES: CHECKOUT OPTIONS: Convenience: - :commit: b990a8c + :commit: 22778d5 :git: https://github.com/keeshux/convenience TunnelKit: :commit: 4d930d3 @@ -84,6 +84,6 @@ SPEC CHECKSUMS: SwiftyBeaver: aaf2ebd7dac2e952991f46a82ed24ad642867ae2 TunnelKit: 0743f0306be0869d51118ac33e274e7507a93537 -PODFILE CHECKSUM: d1e12ff38bc1839dddbbbcaa5e436ea0fad60f70 +PODFILE CHECKSUM: 8099389f0a709d4f175b81d077f47699a651fa42 COCOAPODS: 1.8.4 From e99cc3669d7d9bb337797c57822cbc4b4e04b97d Mon Sep 17 00:00:00 2001 From: Davide De Rosa Date: Mon, 28 Oct 2019 10:07:38 +0100 Subject: [PATCH 4/9] Add stubs for purchase screen With macro for presenting it everywhere. --- .../Base.lproj/Purchase.storyboard | 44 +++++++++++++++++++ Passepartout-iOS/Global/Macros.swift | 6 +++ Passepartout-iOS/Global/SwiftGen+Scenes.swift | 5 +++ .../Purchase/PurchaseViewController.swift | 30 +++++++++++++ Passepartout.xcodeproj/project.pbxproj | 24 ++++++++++ swiftgen.yml | 1 + 6 files changed, 110 insertions(+) create mode 100644 Passepartout-iOS/Base.lproj/Purchase.storyboard create mode 100644 Passepartout-iOS/Scenes/Purchase/PurchaseViewController.swift diff --git a/Passepartout-iOS/Base.lproj/Purchase.storyboard b/Passepartout-iOS/Base.lproj/Purchase.storyboard new file mode 100644 index 00000000..747d3afb --- /dev/null +++ b/Passepartout-iOS/Base.lproj/Purchase.storyboard @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Passepartout-iOS/Global/Macros.swift b/Passepartout-iOS/Global/Macros.swift index a984e45d..68282102 100644 --- a/Passepartout-iOS/Global/Macros.swift +++ b/Passepartout-iOS/Global/Macros.swift @@ -62,6 +62,12 @@ extension UIColor { } } +extension UIViewController { + func presentPurchaseScreen(forProduct product: Product) { + present(StoryboardScene.Purchase.initialScene.instantiate(), animated: true, completion: nil) + } +} + func delay(_ block: @escaping () -> Void) { DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(500)) { block() diff --git a/Passepartout-iOS/Global/SwiftGen+Scenes.swift b/Passepartout-iOS/Global/SwiftGen+Scenes.swift index c019e041..999e5fce 100644 --- a/Passepartout-iOS/Global/SwiftGen+Scenes.swift +++ b/Passepartout-iOS/Global/SwiftGen+Scenes.swift @@ -41,6 +41,11 @@ internal enum StoryboardScene { internal static let wizardHostIdentifier = SceneType(storyboard: Organizer.self, identifier: "WizardHostIdentifier") } + internal enum Purchase: StoryboardType { + internal static let storyboardName = "Purchase" + + internal static let initialScene = InitialSceneType(storyboard: Purchase.self) + } internal enum Shortcuts: StoryboardType { internal static let storyboardName = "Shortcuts" diff --git a/Passepartout-iOS/Scenes/Purchase/PurchaseViewController.swift b/Passepartout-iOS/Scenes/Purchase/PurchaseViewController.swift new file mode 100644 index 00000000..63079fa0 --- /dev/null +++ b/Passepartout-iOS/Scenes/Purchase/PurchaseViewController.swift @@ -0,0 +1,30 @@ +// +// PurchaseViewController.swift +// Passepartout-iOS +// +// Created by Davide De Rosa on 10/27/19. +// Copyright (c) 2019 Davide De Rosa. All rights reserved. +// +// https://github.com/passepartoutvpn +// +// This file is part of Passepartout. +// +// Passepartout is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Passepartout is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Passepartout. If not, see . +// + +import UIKit + +class PurchaseViewController: UIViewController { + +} diff --git a/Passepartout.xcodeproj/project.pbxproj b/Passepartout.xcodeproj/project.pbxproj index 4a7d31b2..3ea1f019 100644 --- a/Passepartout.xcodeproj/project.pbxproj +++ b/Passepartout.xcodeproj/project.pbxproj @@ -68,6 +68,8 @@ 0E45E6E422BD799700F19312 /* SwiftGen+Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E45E6E322BD799700F19312 /* SwiftGen+Strings.swift */; }; 0E45E6FA22BD8FC500F19312 /* Core.strings in Resources */ = {isa = PBXBuildFile; fileRef = 0E3CAF98229AAE760008E5C8 /* Core.strings */; }; 0E45E71022BE108100F19312 /* OpenVPNOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E45E70F22BE108100F19312 /* OpenVPNOptions.swift */; }; + 0E4B0D6B2366E3C100C890B4 /* PurchaseViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E4B0D6A2366E3C000C890B4 /* PurchaseViewController.swift */; }; + 0E4B0D742366E6C800C890B4 /* Purchase.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 0E4B0D762366E6C800C890B4 /* Purchase.storyboard */; }; 0E4C9CBB20DCF0D600A0C59C /* DestructiveTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E4C9CBA20DCF0D600A0C59C /* DestructiveTableViewCell.swift */; }; 0E4FD7F120D58618002221FF /* Macros.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E4FD7F020D58618002221FF /* Macros.swift */; }; 0E533B162258E03B00EF94FC /* PoolGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E533B152258E03B00EF94FC /* PoolGroup.swift */; }; @@ -208,6 +210,8 @@ 0E45E6F822BD898A00F19312 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/App.strings; sourceTree = ""; }; 0E45E6F922BD898B00F19312 /* pt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pt; path = pt.lproj/App.strings; sourceTree = ""; }; 0E45E70F22BE108100F19312 /* OpenVPNOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenVPNOptions.swift; sourceTree = ""; }; + 0E4B0D6A2366E3C000C890B4 /* PurchaseViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PurchaseViewController.swift; sourceTree = ""; }; + 0E4B0D752366E6C800C890B4 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Purchase.storyboard; sourceTree = ""; }; 0E4C9CB820DB9BC600A0C59C /* TrustedNetworks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrustedNetworks.swift; sourceTree = ""; }; 0E4C9CBA20DCF0D600A0C59C /* DestructiveTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DestructiveTableViewCell.swift; sourceTree = ""; }; 0E4FD7DD20D3E49A002221FF /* StandardVPNProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StandardVPNProvider.swift; sourceTree = ""; }; @@ -391,6 +395,14 @@ path = Resources; sourceTree = ""; }; + 0E4B0D6C2366E53C00C890B4 /* Purchase */ = { + isa = PBXGroup; + children = ( + 0E4B0D6A2366E3C000C890B4 /* PurchaseViewController.swift */, + ); + path = Purchase; + sourceTree = ""; + }; 0E4FD7D920D3E43F002221FF /* VPN */ = { isa = PBXGroup; children = ( @@ -448,6 +460,7 @@ 0E24273C225950450064A1A3 /* About.storyboard */, 0E57F63F20C83FC5008323CF /* Main.storyboard */, 0ED38ADC213F44D00004D387 /* Organizer.storyboard */, + 0E4B0D762366E6C800C890B4 /* Purchase.storyboard */, 0E36D25A22403469006AF062 /* Shortcuts.storyboard */, 0E57F64220C83FC7008323CF /* Assets.xcassets */, 0E9CD788225746B300D033B4 /* Flags.xcassets */, @@ -580,6 +593,7 @@ children = ( 0E24273D225950CC0064A1A3 /* About */, 0E89DFCC213EEDE700741BA1 /* Organizer */, + 0E4B0D6C2366E53C00C890B4 /* Purchase */, 0E24273E225950ED0064A1A3 /* Shortcuts */, 0ED31C2820CF2A340027975F /* AccountViewController.swift */, 0ED38AEB2141260D0004D387 /* ConfigurationModificationDelegate.swift */, @@ -812,6 +826,7 @@ 0E9CD7872257462800D033B4 /* Providers.xcassets in Resources */, 0E9CD789225746B300D033B4 /* Flags.xcassets in Resources */, 0E57F64120C83FC5008323CF /* Main.storyboard in Resources */, + 0E4B0D742366E6C800C890B4 /* Purchase.storyboard in Resources */, 0E2AC24522EC3AC10037B4B0 /* Settings.bundle in Resources */, 0E45E6E022BD793800F19312 /* App.strings in Resources */, ); @@ -981,6 +996,7 @@ 0ECEE45020E1182E00A6BB43 /* Theme+Cells.swift in Sources */, 0E242740225951B00064A1A3 /* ProductManager.swift in Sources */, 0E1066C920E0F84A004F98B7 /* Cells.swift in Sources */, + 0E4B0D6B2366E3C100C890B4 /* PurchaseViewController.swift in Sources */, 0EF56BBB2185AC8500B0C8AB /* SwiftGen+Segues.swift in Sources */, 0E05C5D620D1645F006EE732 /* SwiftGen+Scenes.swift in Sources */, 0E773BF8224BF37600CDDC8E /* ShortcutsViewController.swift in Sources */, @@ -1106,6 +1122,14 @@ name = App.strings; sourceTree = ""; }; + 0E4B0D762366E6C800C890B4 /* Purchase.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 0E4B0D752366E6C800C890B4 /* Base */, + ); + name = Purchase.storyboard; + sourceTree = ""; + }; 0E57F63F20C83FC5008323CF /* Main.storyboard */ = { isa = PBXVariantGroup; children = ( diff --git a/swiftgen.yml b/swiftgen.yml index 855481ab..fe9ab66e 100644 --- a/swiftgen.yml +++ b/swiftgen.yml @@ -11,6 +11,7 @@ ib: - Passepartout-iOS/Base.lproj/About.storyboard - Passepartout-iOS/Base.lproj/Main.storyboard - Passepartout-iOS/Base.lproj/Organizer.storyboard + - Passepartout-iOS/Base.lproj/Purchase.storyboard - Passepartout-iOS/Base.lproj/Shortcuts.storyboard outputs: - templateName: scenes-swift4 From 6e46757d998798ca72365cf2a8ee630371f2f739 Mon Sep 17 00:00:00 2001 From: Davide De Rosa Date: Mon, 28 Oct 2019 10:14:03 +0100 Subject: [PATCH 5/9] Verify feature/provider eligibility Limit hosts to 2. --- Passepartout-iOS/AppDelegate.swift | 7 ++++++- Passepartout-iOS/Global/Product.swift | 6 ++++++ Passepartout-iOS/Global/ProductManager.swift | 7 +++++++ .../ImportedHostsViewController.swift | 2 +- .../Organizer/OrganizerViewController.swift | 10 +++++++++ .../WizardProviderViewController.swift | 5 +++++ .../Scenes/ServiceViewController.swift | 21 +++++++++++++++++++ 7 files changed, 56 insertions(+), 2 deletions(-) diff --git a/Passepartout-iOS/AppDelegate.swift b/Passepartout-iOS/AppDelegate.swift index f77b31a8..cfbbd94c 100644 --- a/Passepartout-iOS/AppDelegate.swift +++ b/Passepartout-iOS/AppDelegate.swift @@ -97,8 +97,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UISplitViewControllerDele guard let root = window?.rootViewController else { fatalError("No window.rootViewController?") } - let topmost = root.presentedViewController ?? root + if TransientStore.shared.service.hasReachedMaximumNumberOfHosts { + guard ProductManager.shared.isEligible(forFeature: .unlimitedHosts) else { + topmost.presentPurchaseScreen(forProduct: .unlimitedHosts) + return false + } + } return tryParseURL(url, passphrase: nil, target: topmost) } diff --git a/Passepartout-iOS/Global/Product.swift b/Passepartout-iOS/Global/Product.swift index f3ff41cb..5558c5ee 100644 --- a/Passepartout-iOS/Global/Product.swift +++ b/Passepartout-iOS/Global/Product.swift @@ -107,3 +107,9 @@ extension Infrastructure.Name { return product } } + +extension AppConstants { + struct InApp { + public static let limitedNumberOfHosts = 2 + } +} diff --git a/Passepartout-iOS/Global/ProductManager.swift b/Passepartout-iOS/Global/ProductManager.swift index 9fb543a2..eb2d03f6 100644 --- a/Passepartout-iOS/Global/ProductManager.swift +++ b/Passepartout-iOS/Global/ProductManager.swift @@ -159,6 +159,13 @@ class ProductManager: NSObject { } } +extension ConnectionService { + var hasReachedMaximumNumberOfHosts: Bool { + let numberOfHosts = ids(forContext: .host).count + return numberOfHosts >= AppConstants.InApp.limitedNumberOfHosts + } +} + extension ProductManager: SKPaymentTransactionObserver { func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) { reloadReceipt() diff --git a/Passepartout-iOS/Scenes/Organizer/ImportedHostsViewController.swift b/Passepartout-iOS/Scenes/Organizer/ImportedHostsViewController.swift index 225bc24b..86bb59b1 100644 --- a/Passepartout-iOS/Scenes/Organizer/ImportedHostsViewController.swift +++ b/Passepartout-iOS/Scenes/Organizer/ImportedHostsViewController.swift @@ -71,7 +71,7 @@ class ImportedHostsViewController: UITableViewController { // MARK: Actions @IBAction private func openConfigurationFile() { - let picker = UIDocumentPickerViewController(documentTypes: ["public.content", "public.data"], in: .import) + let picker = UIDocumentPickerViewController(documentTypes: AppConstants.URLs.filetypes, in: .import) picker.allowsMultipleSelection = false picker.delegate = self present(picker, animated: true, completion: nil) diff --git a/Passepartout-iOS/Scenes/Organizer/OrganizerViewController.swift b/Passepartout-iOS/Scenes/Organizer/OrganizerViewController.swift index f690b69c..6e07f843 100644 --- a/Passepartout-iOS/Scenes/Organizer/OrganizerViewController.swift +++ b/Passepartout-iOS/Scenes/Organizer/OrganizerViewController.swift @@ -204,10 +204,20 @@ class OrganizerViewController: UITableViewController, StrongTableHost { } private func addNewHost() { + if TransientStore.shared.service.hasReachedMaximumNumberOfHosts { + guard ProductManager.shared.isEligible(forFeature: .unlimitedHosts) else { + presentPurchaseScreen(forProduct: .unlimitedHosts) + return + } + } perform(segue: StoryboardSegue.Organizer.showImportedHostsSegueIdentifier) } private func addShortcuts() { + guard ProductManager.shared.isEligible(forFeature: .siriShortcuts) else { + presentPurchaseScreen(forProduct: .siriShortcuts) + return + } perform(segue: StoryboardSegue.Organizer.siriShortcutsSegueIdentifier) } diff --git a/Passepartout-iOS/Scenes/Organizer/WizardProviderViewController.swift b/Passepartout-iOS/Scenes/Organizer/WizardProviderViewController.swift index 02553939..18e12271 100644 --- a/Passepartout-iOS/Scenes/Organizer/WizardProviderViewController.swift +++ b/Passepartout-iOS/Scenes/Organizer/WizardProviderViewController.swift @@ -38,6 +38,11 @@ class WizardProviderViewController: UITableViewController { } private func next(withName name: Infrastructure.Name) { + guard ProductManager.shared.isEligible(forProvider: name) else { + presentPurchaseScreen(forProduct: name.product) + return + } + let profile = ProviderConnectionProfile(name: name) createdProfile = profile diff --git a/Passepartout-iOS/Scenes/ServiceViewController.swift b/Passepartout-iOS/Scenes/ServiceViewController.swift index 2f1d95e5..8d206faf 100644 --- a/Passepartout-iOS/Scenes/ServiceViewController.swift +++ b/Passepartout-iOS/Scenes/ServiceViewController.swift @@ -347,6 +347,14 @@ class ServiceViewController: UIViewController, StrongTableHost { } private func trustMobileNetwork(cell: ToggleTableViewCell) { + guard ProductManager.shared.isEligible(forFeature: .trustedNetworks) else { + delay { + cell.setOn(false, animated: true) + } + presentPurchaseScreen(forProduct: .trustedNetworks) + return + } + if #available(iOS 12, *) { IntentDispatcher.donateTrustCellularNetwork() IntentDispatcher.donateUntrustCellularNetwork() @@ -356,6 +364,11 @@ class ServiceViewController: UIViewController, StrongTableHost { } private func trustCurrentWiFi() { + guard ProductManager.shared.isEligible(forFeature: .trustedNetworks) else { + presentPurchaseScreen(forProduct: .trustedNetworks) + return + } + if #available(iOS 13, *) { let auth = CLLocationManager.authorizationStatus() switch auth { @@ -400,6 +413,14 @@ class ServiceViewController: UIViewController, StrongTableHost { } private func toggleTrustWiFi(cell: ToggleTableViewCell, at row: Int) { + guard ProductManager.shared.isEligible(forFeature: .trustedNetworks) else { + delay { + cell.setOn(false, animated: true) + } + presentPurchaseScreen(forProduct: .trustedNetworks) + return + } + if cell.isOn { trustedNetworks.enableWifi(at: row) } else { From 1e6c5ba91bc33eb4daa33c9c23e0cdf05080075f Mon Sep 17 00:00:00 2001 From: Davide De Rosa Date: Wed, 30 Oct 2019 13:12:58 +0100 Subject: [PATCH 6/9] Design purchase screen - Required product - Full version - Restore purchases --- .../Base.lproj/Purchase.storyboard | 74 ++++++-- Passepartout-iOS/Global/Macros.swift | 5 +- .../Global/SwiftGen+Strings.swift | 12 ++ Passepartout-iOS/Global/Theme.swift | 4 + Passepartout-iOS/Global/en.lproj/App.strings | 4 + .../Purchase/PurchaseTableViewCell.swift | 55 ++++++ .../Purchase/PurchaseViewController.swift | 177 +++++++++++++++++- Passepartout.xcodeproj/project.pbxproj | 4 + 8 files changed, 321 insertions(+), 14 deletions(-) create mode 100644 Passepartout-iOS/Scenes/Purchase/PurchaseTableViewCell.swift diff --git a/Passepartout-iOS/Base.lproj/Purchase.storyboard b/Passepartout-iOS/Base.lproj/Purchase.storyboard index 747d3afb..ac1989bc 100644 --- a/Passepartout-iOS/Base.lproj/Purchase.storyboard +++ b/Passepartout-iOS/Base.lproj/Purchase.storyboard @@ -4,7 +4,6 @@ - @@ -17,7 +16,7 @@ - + @@ -25,20 +24,71 @@ - + - - + + - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + diff --git a/Passepartout-iOS/Global/Macros.swift b/Passepartout-iOS/Global/Macros.swift index 68282102..990cc961 100644 --- a/Passepartout-iOS/Global/Macros.swift +++ b/Passepartout-iOS/Global/Macros.swift @@ -64,7 +64,10 @@ extension UIColor { extension UIViewController { func presentPurchaseScreen(forProduct product: Product) { - present(StoryboardScene.Purchase.initialScene.instantiate(), animated: true, completion: nil) + let nav = StoryboardScene.Purchase.initialScene.instantiate() + let vc = nav.topViewController as? PurchaseViewController + vc?.feature = product + present(nav, animated: true, completion: nil) } } diff --git a/Passepartout-iOS/Global/SwiftGen+Strings.swift b/Passepartout-iOS/Global/SwiftGen+Strings.swift index 067b35a5..1ec505c6 100644 --- a/Passepartout-iOS/Global/SwiftGen+Strings.swift +++ b/Passepartout-iOS/Global/SwiftGen+Strings.swift @@ -78,6 +78,18 @@ internal enum L10n { } } } + internal enum Purchase { + /// Purchase + internal static let title = L10n.tr("App", "purchase.title") + internal enum Cells { + internal enum Restore { + /// If you bought this app or feature in the past, you can restore your purchases and this screen won't show again. + internal static let description = L10n.tr("App", "purchase.cells.restore.description") + /// Restore purchases + internal static let title = L10n.tr("App", "purchase.cells.restore.title") + } + } + } internal enum Service { internal enum Alerts { internal enum Location { diff --git a/Passepartout-iOS/Global/Theme.swift b/Passepartout-iOS/Global/Theme.swift index de07270a..16f84761 100644 --- a/Passepartout-iOS/Global/Theme.swift +++ b/Passepartout-iOS/Global/Theme.swift @@ -130,6 +130,10 @@ extension UILabel { func applyLight(_ theme: Theme) { textColor = theme.palette.primaryLightText } + + func applyAccent(_ theme: Theme) { + textColor = theme.palette.accent1 + } } extension UIButton { diff --git a/Passepartout-iOS/Global/en.lproj/App.strings b/Passepartout-iOS/Global/en.lproj/App.strings index 99907bb3..ec407710 100644 --- a/Passepartout-iOS/Global/en.lproj/App.strings +++ b/Passepartout-iOS/Global/en.lproj/App.strings @@ -59,3 +59,7 @@ "shortcuts.edit.title" = "Manage shortcuts"; "shortcuts.edit.cells.add_shortcut.caption" = "Add shortcut"; + +"purchase.title" = "Purchase"; +"purchase.cells.restore.title" = "Restore purchases"; +"purchase.cells.restore.description" = "If you bought this app or feature in the past, you can restore your purchases and this screen won't show again."; diff --git a/Passepartout-iOS/Scenes/Purchase/PurchaseTableViewCell.swift b/Passepartout-iOS/Scenes/Purchase/PurchaseTableViewCell.swift new file mode 100644 index 00000000..8efbab02 --- /dev/null +++ b/Passepartout-iOS/Scenes/Purchase/PurchaseTableViewCell.swift @@ -0,0 +1,55 @@ +// +// PurchaseTableViewCell.swift +// Passepartout-iOS +// +// Created by Davide De Rosa on 10/30/19. +// Copyright (c) 2019 Davide De Rosa. All rights reserved. +// +// https://github.com/passepartoutvpn +// +// This file is part of Passepartout. +// +// Passepartout is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Passepartout is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Passepartout. If not, see . +// + +import UIKit +import StoreKit + +class PurchaseTableViewCell: UITableViewCell { + @IBOutlet private weak var labelTitle: UILabel? + + @IBOutlet private weak var labelDescription: UILabel? + + override func awakeFromNib() { + super.awakeFromNib() + + labelTitle?.applyAccent(.current) + } + + func fill(product: SKProduct) { + var title = product.localizedTitle + if let price = product.localizedPrice { + title += " @ \(price)" + } + fill( + title: title, + description: "\(product.localizedDescription)." + ) + } + + func fill(title: String, description: String) { + labelTitle?.text = title + labelDescription?.text = description + } +} diff --git a/Passepartout-iOS/Scenes/Purchase/PurchaseViewController.swift b/Passepartout-iOS/Scenes/Purchase/PurchaseViewController.swift index 63079fa0..10b029dc 100644 --- a/Passepartout-iOS/Scenes/Purchase/PurchaseViewController.swift +++ b/Passepartout-iOS/Scenes/Purchase/PurchaseViewController.swift @@ -24,7 +24,182 @@ // import UIKit +import StoreKit +import SwiftyBeaver +import Convenience -class PurchaseViewController: UIViewController { +private let log = SwiftyBeaver.self + +class PurchaseViewController: UITableViewController, StrongTableHost { + private var isLoading = true + + var feature: Product! + + private var skFeature: SKProduct? + + private var skFullVersion: SKProduct? + + // MARK: StrongTableHost + var model: StrongTableModel = StrongTableModel() + + func reloadModel() { + model.clear() + model.add(.products) + + var rows: [RowType] = [] + let pm = ProductManager.shared + if let skFeature = pm.product(withIdentifier: feature) { + self.skFeature = skFeature + rows.append(.feature) + } + if let skFullVersion = pm.product(withIdentifier: .fullVersion) { + self.skFullVersion = skFullVersion + rows.append(.fullVersion) + } + rows.append(.restore) + model.set(rows, forSection: .products) + } + + // MARK: UIViewController + + override func viewDidLoad() { + super.viewDidLoad() + + guard let _ = feature else { + fatalError("No feature set for purchase") + } + + title = L10n.App.Purchase.title + + // enforce pre iOS 13 behavior + if #available(iOS 13, *) { + isModalInPresentation = true + } + navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .stop, target: self, action: #selector(close)) + + isLoading = true + tableView.reloadData() + + let hud = HUD(view: view) + ProductManager.shared.listProducts { [weak self] _ in + self?.reloadModel() + self?.isLoading = false + self?.tableView.reloadData() + hud.hide() + } + } + + // MARK: Actions + + private func purchaseFeature() { + guard let sk = skFeature else { + return + } + purchase(sk) + } + + private func purchaseFullVersion() { + guard let sk = skFullVersion else { + return + } + purchase(sk) + } + + private func restorePurchases() { + let hud = HUD(view: view) + ProductManager.shared.restorePurchases { [weak self] in + hud.hide() + guard $0 == nil else { + return + } + self?.dismiss(animated: true, completion: nil) + } + } + + private func purchase(_ skProduct: SKProduct) { + let hud = HUD(view: view) + ProductManager.shared.purchase(skProduct) { [weak self] in + hud.hide() + guard $0 == .success else { + if let error = $1 { + self?.reportPurchaseError(withProduct: skProduct, error: error) + } + return + } + self?.dismiss(animated: true, completion: nil) + } + } + + private func reportPurchaseError(withProduct product: SKProduct, error: Error) { + log.error("Unable to purchase \(product): \(error)") + + let alert = UIAlertController.asAlert(product.localizedTitle, error.localizedDescription) + alert.addCancelAction(L10n.Core.Global.ok) + present(alert, animated: true, completion: nil) + } + + @objc private func close() { + dismiss(animated: true, completion: nil) + } +} + +extension PurchaseViewController { + enum SectionType { + case products + } + + enum RowType { + case feature + + case fullVersion + + case restore + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + guard !isLoading else { + return 0 + } + return model.numberOfRows(forSection: section) + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "PurchaseTableViewCell", for: indexPath) as! PurchaseTableViewCell + switch model.row(at: indexPath) { + case .feature: + guard let product = skFeature else { + fatalError("Loaded feature cell, yet no corresponding product?") + } + cell.fill(product: product) + + case .fullVersion: + guard let product = skFullVersion else { + fatalError("Loaded full version cell, yet no corresponding product?") + } + cell.fill(product: product) + + case .restore: + cell.fill( + title: L10n.App.Purchase.Cells.Restore.title, + description: L10n.App.Purchase.Cells.Restore.description + ) + } + return cell + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + + switch model.row(at: indexPath) { + case .feature: + purchaseFeature() + + case .fullVersion: + purchaseFullVersion() + + case .restore: + restorePurchases() + } + } } diff --git a/Passepartout.xcodeproj/project.pbxproj b/Passepartout.xcodeproj/project.pbxproj index 3ea1f019..dbb037c5 100644 --- a/Passepartout.xcodeproj/project.pbxproj +++ b/Passepartout.xcodeproj/project.pbxproj @@ -78,6 +78,7 @@ 0E57F64120C83FC5008323CF /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 0E57F63F20C83FC5008323CF /* Main.storyboard */; }; 0E57F64320C83FC7008323CF /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0E57F64220C83FC7008323CF /* Assets.xcassets */; }; 0E57F64620C83FC7008323CF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 0E57F64420C83FC7008323CF /* LaunchScreen.storyboard */; }; + 0E6268942369AD0600355F75 /* PurchaseTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E6268932369AD0600355F75 /* PurchaseTableViewCell.swift */; }; 0E66A270225FE25800F9C779 /* PoolCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E66A26F225FE25800F9C779 /* PoolCategory.swift */; }; 0E6BE13F20CFBAB300A6DD36 /* DebugLogViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E6BE13E20CFBAB300A6DD36 /* DebugLogViewController.swift */; }; 0E773BF8224BF37600CDDC8E /* ShortcutsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E773BF7224BF37600CDDC8E /* ShortcutsViewController.swift */; }; @@ -227,6 +228,7 @@ 0E5E5DDE215119AF00E318A3 /* VPNStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNStatus.swift; sourceTree = ""; }; 0E5E5DE1215119DD00E318A3 /* VPNConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNConfiguration.swift; sourceTree = ""; }; 0E5E5DE421511C5F00E318A3 /* GracefulVPN.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GracefulVPN.swift; sourceTree = ""; }; + 0E6268932369AD0600355F75 /* PurchaseTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchaseTableViewCell.swift; sourceTree = ""; }; 0E66A26F225FE25800F9C779 /* PoolCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PoolCategory.swift; path = ../Model/Profiles/PoolCategory.swift; sourceTree = ""; }; 0E6ACB7722B1A57C001B3C99 /* pt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pt; path = pt.lproj/Intents.strings; sourceTree = ""; }; 0E6ACB7822B1A5BB001B3C99 /* pt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pt; path = pt.lproj/Core.strings; sourceTree = ""; }; @@ -398,6 +400,7 @@ 0E4B0D6C2366E53C00C890B4 /* Purchase */ = { isa = PBXGroup; children = ( + 0E6268932369AD0600355F75 /* PurchaseTableViewCell.swift */, 0E4B0D6A2366E3C000C890B4 /* PurchaseViewController.swift */, ); path = Purchase; @@ -995,6 +998,7 @@ 0E776642229D0DAE0023FA76 /* Intents.intentdefinition in Sources */, 0ECEE45020E1182E00A6BB43 /* Theme+Cells.swift in Sources */, 0E242740225951B00064A1A3 /* ProductManager.swift in Sources */, + 0E6268942369AD0600355F75 /* PurchaseTableViewCell.swift in Sources */, 0E1066C920E0F84A004F98B7 /* Cells.swift in Sources */, 0E4B0D6B2366E3C100C890B4 /* PurchaseViewController.swift in Sources */, 0EF56BBB2185AC8500B0C8AB /* SwiftGen+Segues.swift in Sources */, From b77f67767153a76ad37b008c3038eb4290899852 Mon Sep 17 00:00:00 2001 From: Davide De Rosa Date: Wed, 30 Oct 2019 13:58:30 +0100 Subject: [PATCH 7/9] Present purchase as full screen --- Passepartout-iOS/Global/Macros.swift | 4 ++++ .../Scenes/Purchase/PurchaseViewController.swift | 5 ----- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/Passepartout-iOS/Global/Macros.swift b/Passepartout-iOS/Global/Macros.swift index 990cc961..088bcd29 100644 --- a/Passepartout-iOS/Global/Macros.swift +++ b/Passepartout-iOS/Global/Macros.swift @@ -67,6 +67,10 @@ extension UIViewController { let nav = StoryboardScene.Purchase.initialScene.instantiate() let vc = nav.topViewController as? PurchaseViewController vc?.feature = product + + // enforce pre iOS 13 behavior + nav.modalPresentationStyle = .fullScreen + present(nav, animated: true, completion: nil) } } diff --git a/Passepartout-iOS/Scenes/Purchase/PurchaseViewController.swift b/Passepartout-iOS/Scenes/Purchase/PurchaseViewController.swift index 10b029dc..224251aa 100644 --- a/Passepartout-iOS/Scenes/Purchase/PurchaseViewController.swift +++ b/Passepartout-iOS/Scenes/Purchase/PurchaseViewController.swift @@ -71,11 +71,6 @@ class PurchaseViewController: UITableViewController, StrongTableHost { } title = L10n.App.Purchase.title - - // enforce pre iOS 13 behavior - if #available(iOS 13, *) { - isModalInPresentation = true - } navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .stop, target: self, action: #selector(close)) isLoading = true From 395ee83981e61b3acb3b76caecc5bb7d03bfe548 Mon Sep 17 00:00:00 2001 From: Davide De Rosa Date: Wed, 30 Oct 2019 15:30:36 +0100 Subject: [PATCH 8/9] Add Kvitto to credits --- README.md | 3 ++- Submodules/Core | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index fd506231..cae82223 100644 --- a/README.md +++ b/README.md @@ -130,12 +130,13 @@ The logo is taken from the awesome Circle Icons set by Nick Roach. The country flags are taken from: +- Kvitto - © 2015 Oliver Drobnik +- lzo - © 1996-2017 Markus F.X.J. Oberhumer - MBProgressHUD - © 2009-2016 Matej Bukovinski - PIATunnel - © 2018-Present Private Internet Access - SSZipArchive - © 2010-2012 Sam Soffes - SwiftGen - © 2018 SwiftGen - SwiftyBeaver - © 2015 Sebastian Kreutzberger -- lzo - © 1996-2017 Markus F.X.J. Oberhumer This product includes software developed by the OpenSSL Project for use in the OpenSSL Toolkit. ([https://www.openssl.org/][dep-openssl]) diff --git a/Submodules/Core b/Submodules/Core index 4fb30619..22f214e9 160000 --- a/Submodules/Core +++ b/Submodules/Core @@ -1 +1 @@ -Subproject commit 4fb306195bbb71973adf4d03290952226c2b5dab +Subproject commit 22f214e9ab03da3087a5818882e3704b98711bc4 From 5a7adf0721c518607abddec17fac48cb4e37cec2 Mon Sep 17 00:00:00 2001 From: Davide De Rosa Date: Thu, 31 Oct 2019 20:53:47 +0100 Subject: [PATCH 9/9] Interpret originalAppVersion as build number --- Passepartout-iOS/Global/ProductManager.swift | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Passepartout-iOS/Global/ProductManager.swift b/Passepartout-iOS/Global/ProductManager.swift index eb2d03f6..143360fa 100644 --- a/Passepartout-iOS/Global/ProductManager.swift +++ b/Passepartout-iOS/Global/ProductManager.swift @@ -33,15 +33,13 @@ import PassepartoutCore private let log = SwiftyBeaver.self class ProductManager: NSObject { - private static let lastFullVersionNumber = "1.8.1" - - private static let lastFullVersionBuild = "2016" + private static let lastFullVersionBuild = 2016 // 1.8.1 static let shared = ProductManager() private let inApp: InApp - private var purchasedAppVersion: String? + private var purchasedAppBuild: Int? private(set) var purchasedFeatures: Set @@ -51,7 +49,7 @@ class ProductManager: NSObject { private override init() { inApp = InApp() - purchasedAppVersion = nil + purchasedAppBuild = nil purchasedFeatures = [] super.init() @@ -106,14 +104,16 @@ class ProductManager: NSObject { return } - purchasedAppVersion = receipt.originalAppVersion + if let originalAppVersion = receipt.originalAppVersion, let buildNumber = Int(originalAppVersion) { + purchasedAppBuild = buildNumber + } purchasedFeatures.removeAll() - if let version = purchasedAppVersion { - log.debug("Original purchased version: \(version)") + if let buildNumber = purchasedAppBuild { + log.debug("Original purchased build: \(buildNumber)") // treat former purchases as full versions - if version <= ProductManager.lastFullVersionNumber { + if buildNumber <= ProductManager.lastFullVersionBuild { purchasedFeatures.insert(.fullVersion) } }