Simulate in-app purchases (#818)

Integrate in-app helper into IAPManager and simulate purchases with an
in-memory receipt.
This commit is contained in:
Davide 2024-11-05 18:55:57 +01:00 committed by GitHub
parent 9351ceeb6a
commit d5ac785bb8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 228 additions and 27 deletions

View File

@ -63,6 +63,7 @@ struct OnDemandView: View, ModuleDraftEditing {
.modifier(PurchaseButtonModifier(
Strings.Modules.OnDemand.purchase,
feature: .onDemand,
suggesting: nil,
showsIfRestricted: false,
paywallReason: $paywallReason
))

View File

@ -59,6 +59,7 @@ private extension AppleTVSection {
.modifier(PurchaseButtonModifier(
Strings.Modules.General.Rows.AppleTv.purchase,
feature: .appleTV,
suggesting: .Features.appleTV,
showsIfRestricted: true,
paywallReason: $paywallReason
))
@ -72,7 +73,7 @@ private extension AppleTVSection {
let purchaseDesc = {
Strings.Modules.General.Sections.AppleTv.Footer.Purchase._2
}
switch iapManager.paywallReason(forFeature: .appleTV) {
switch iapManager.paywallReason(forFeature: .appleTV, suggesting: nil) {
case .purchase:
desc.append(expirationDesc())
desc.append(purchaseDesc())

View File

@ -104,13 +104,13 @@ private extension ProfileCoordinator {
func onNewModule(_ moduleType: ModuleType) {
switch moduleType {
case .dns:
paywallReason = iapManager.paywallReason(forFeature: .dns)
paywallReason = iapManager.paywallReason(forFeature: .dns, suggesting: nil)
case .httpProxy:
paywallReason = iapManager.paywallReason(forFeature: .httpProxy)
paywallReason = iapManager.paywallReason(forFeature: .httpProxy, suggesting: nil)
case .ip:
paywallReason = iapManager.paywallReason(forFeature: .routing)
paywallReason = iapManager.paywallReason(forFeature: .routing, suggesting: nil)
case .openVPN, .wireGuard:
break

View File

@ -137,6 +137,7 @@ private extension ProviderContentModifier {
.modifier(PurchaseButtonModifier(
Strings.Providers.Picker.purchase,
feature: .providers,
suggesting: nil,
showsIfRestricted: true,
paywallReason: $paywallReason
))

View File

@ -31,6 +31,9 @@ extension AppProduct {
public static let appleTV = AppProduct(featureId: "appletv")
// FIXME: #585, add in-app product
public static let interactiveLogin = AppProduct(featureId: "interactive_login")
public static let networkSettings = AppProduct(featureId: "network_settings")
public static let siriShortcuts = AppProduct(featureId: "siri")
@ -40,6 +43,7 @@ extension AppProduct {
static let all: [AppProduct] = [
.Features.allProviders,
.Features.appleTV,
.Features.interactiveLogin,
.Features.networkSettings,
.Features.siriShortcuts,
.Features.trustedNetworks

View File

@ -27,12 +27,12 @@ import CommonUtils
import Foundation
import PassepartoutKit
// FIXME: #424, reload receipt + objectWillChange on purchase/transactions
@MainActor
public final class IAPManager: ObservableObject {
private let customUserLevel: AppUserLevel?
private let inAppHelper: any AppProductHelper
private let receiptReader: any AppReceiptReader
private let unrestrictedFeatures: Set<AppFeature>
@ -49,17 +49,56 @@ public final class IAPManager: ObservableObject {
public init(
customUserLevel: AppUserLevel? = nil,
inAppHelper: any AppProductHelper,
receiptReader: any AppReceiptReader,
unrestrictedFeatures: Set<AppFeature> = [],
productsAtBuild: BuildProducts<AppProduct>? = nil
) {
self.customUserLevel = customUserLevel
self.inAppHelper = inAppHelper
self.receiptReader = receiptReader
self.unrestrictedFeatures = unrestrictedFeatures
self.productsAtBuild = productsAtBuild
userLevel = .undefined
purchasedProducts = []
eligibleFeatures = []
Task {
do {
try await inAppHelper.fetchProducts()
} catch {
pp_log(.app, .error, "Unable to fetch in-app products: \(error)")
}
}
}
public func products(for identifiers: Set<AppProduct>) async -> [InAppProduct] {
let raw = identifiers.map(\.rawValue)
return await inAppHelper.products
.values
.filter {
raw.contains($0.productIdentifier)
}
}
public func purchase(_ inAppProduct: InAppProduct) async throws -> InAppPurchaseResult {
guard let product = AppProduct(rawValue: inAppProduct.productIdentifier) else {
return .notFound
}
return try await purchase(product)
}
public func purchase(_ product: AppProduct) async throws -> InAppPurchaseResult {
let result = try await inAppHelper.purchase(productWithIdentifier: product)
if result == .done {
await reloadReceipt()
}
return result
}
public func restorePurchases() async throws {
try await inAppHelper.restorePurchases()
await reloadReceipt()
}
public func reloadReceipt() async {
@ -166,11 +205,11 @@ extension IAPManager {
#endif
}
public func paywallReason(forFeature feature: AppFeature) -> PaywallReason? {
public func paywallReason(forFeature feature: AppFeature, suggesting product: AppProduct?) -> PaywallReason? {
if isEligible(for: feature) {
return nil
}
return isRestricted ? .restricted : .purchase(feature)
return isRestricted ? .restricted : .purchase(feature, product)
}
public func isPayingUser() -> Bool {

View File

@ -28,5 +28,5 @@ import Foundation
public enum PaywallReason: Hashable {
case restricted
case purchase(AppFeature)
case purchase(AppFeature, AppProduct?)
}

View File

@ -27,10 +27,17 @@ import CommonUtils
import Foundation
public actor MockAppProductHelper: AppProductHelper {
private let build: Int
public private(set) var products: [AppProduct: InAppProduct]
public init() {
public nonisolated let receiptReader: MockAppReceiptReader
// set .max to skip entitled products
public init(build: Int = .max) {
self.build = build
products = [:]
receiptReader = MockAppReceiptReader()
}
public nonisolated var canMakePurchases: Bool {
@ -42,14 +49,16 @@ public actor MockAppProductHelper: AppProductHelper {
$0[$1] = InAppProduct(
productIdentifier: $1.rawValue,
localizedTitle: $1.rawValue,
localizedPrice: "10.0",
localizedPrice: "10.0",
native: $1
)
}
await receiptReader.setReceipt(withBuild: build, products: [])
}
public func purchase(productWithIdentifier productIdentifier: AppProduct) async throws -> InAppPurchaseResult {
.done
await receiptReader.addPurchase(with: productIdentifier)
return .done
}
public func restorePurchases() async throws {

View File

@ -35,13 +35,38 @@ public actor MockAppReceiptReader: AppReceiptReader {
public func setReceipt(withBuild build: Int, products: [AppProduct], cancelledProducts: Set<AppProduct> = []) {
receipt = InAppReceipt(originalBuildNumber: build, purchaseReceipts: products.map {
.init(productIdentifier: $0.rawValue,
cancellationDate: cancelledProducts.contains($0) ? Date() : nil,
originalPurchaseDate: nil)
.init(
productIdentifier: $0.rawValue,
cancellationDate: cancelledProducts.contains($0) ? Date() : nil,
originalPurchaseDate: nil
)
})
}
public func receipt(for userLevel: AppUserLevel) -> InAppReceipt? {
receipt
}
public func addPurchase(with product: AppProduct) {
guard let receipt else {
fatalError("Call setReceipt() first")
}
var purchaseReceipts = receipt.purchaseReceipts ?? []
purchaseReceipts.append(product.purchaseReceipt)
let newReceipt = InAppReceipt(
originalBuildNumber: receipt.originalBuildNumber,
purchaseReceipts: purchaseReceipts
)
self.receipt = newReceipt
}
}
private extension AppProduct {
var purchaseReceipt: InAppReceipt.PurchaseReceipt {
.init(
productIdentifier: rawValue,
cancellationDate: nil,
originalPurchaseDate: nil
)
}
}

View File

@ -83,12 +83,12 @@ public final class StoreKitHelper<PID>: InAppHelper where PID: RawRepresentable
}
}
// FIXME: #424, implement purchase
// FIXME: #585, implement purchase
public func purchase(productWithIdentifier productIdentifier: ProductIdentifier) async throws -> InAppPurchaseResult {
fatalError("purchase")
}
// FIXME: #424, implement restore purchases
// FIXME: #585, implement restore purchases
public func restorePurchases() async throws {
fatalError("restorePurchases")
}

View File

@ -0,0 +1,58 @@
//
// AppFeature+L10n.swift
// Passepartout
//
// Created by Davide De Rosa on 11/5/24.
// Copyright (c) 2024 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 <http://www.gnu.org/licenses/>.
//
import CommonLibrary
import CommonUtils
import Foundation
extension AppFeature: LocalizableEntity {
public var localizedDescription: String {
switch self {
case .appleTV:
return Strings.Unlocalized.appleTV
case .dns:
return Strings.Unlocalized.dns
case .httpProxy:
return Strings.Unlocalized.httpProxy
case .interactiveLogin:
return Strings.Features.interactiveLogin
case .onDemand:
return Strings.Global.onDemand
case .providers:
return Strings.Features.providers
case .routing:
return Strings.Global.routing
case .siri:
return Strings.Features.siri
}
}
}

View File

@ -174,6 +174,14 @@ public enum Strings {
public static let tls = Strings.tr("Localizable", "errors.tunnel.tls", fallback: "TLS failed")
}
}
public enum Features {
/// Interactive login
public static let interactiveLogin = Strings.tr("Localizable", "features.interactive_login", fallback: "Interactive login")
/// Providers
public static let providers = Strings.tr("Localizable", "features.providers", fallback: "Providers")
/// Shortcuts
public static let siri = Strings.tr("Localizable", "features.siri", fallback: "Shortcuts")
}
public enum Global {
/// About
public static let about = Strings.tr("Localizable", "global.about", fallback: "About")

View File

@ -35,6 +35,7 @@ extension AppContext {
public static func mock(withRegistry registry: Registry) -> AppContext {
let iapManager = IAPManager(
customUserLevel: nil,
inAppHelper: MockAppProductHelper(),
receiptReader: MockAppReceiptReader(),
unrestrictedFeatures: [
.interactiveLogin,

View File

@ -252,6 +252,10 @@
// MARK: - Paywalls
"features.interactive_login" = "Interactive login";
"features.providers" = "Providers";
"features.siri" = "Shortcuts";
"modules.general.sections.apple_tv.footer.purchase.1" = "TV profiles expire after %d minutes.";
"modules.general.sections.apple_tv.footer.purchase.2" = "Purchase to drop the restriction.";
"modules.general.rows.apple_tv.purchase" = "Drop time restriction";

View File

@ -77,6 +77,7 @@ public struct OpenVPNCredentialsView: View {
.modifier(PurchaseButtonModifier(
Strings.Modules.Openvpn.Credentials.Interactive.purchase,
feature: .interactiveLogin,
suggesting: .Features.interactiveLogin,
showsIfRestricted: false,
paywallReason: $paywallReason
))

View File

@ -35,7 +35,7 @@ public struct PaywallModifier: ViewModifier {
private var isPresentingRestricted = false
@State
private var paywallFeature: AppFeature?
private var paywallArguments: PaywallArguments?
public init(reason: Binding<PaywallReason?>) {
_reason = reason
@ -56,9 +56,13 @@ public struct PaywallModifier: ViewModifier {
Text(Strings.Alerts.Iap.Restricted.message)
}
)
.themeModal(item: $paywallFeature) { feature in
.themeModal(item: $paywallArguments) { args in
NavigationStack {
PaywallView(isPresented: isPresentingPurchase, feature: feature)
PaywallView(
isPresented: isPresentingPurchase,
feature: args.feature,
suggestedProduct: args.product
)
}
}
.onChange(of: reason) {
@ -66,8 +70,8 @@ public struct PaywallModifier: ViewModifier {
case .restricted:
isPresentingRestricted = true
case .purchase(let feature):
paywallFeature = feature
case .purchase(let feature, let product):
paywallArguments = PaywallArguments(feature: feature, product: product)
default:
break
@ -79,11 +83,23 @@ public struct PaywallModifier: ViewModifier {
private extension PaywallModifier {
var isPresentingPurchase: Binding<Bool> {
Binding {
paywallFeature != nil
paywallArguments != nil
} set: {
if !$0 {
paywallFeature = nil
// make sure to reset this to allow paywall to appear again
reason = nil
paywallArguments = nil
}
}
}
}
private struct PaywallArguments: Identifiable {
let feature: AppFeature
let product: AppProduct?
var id: String {
feature.id
}
}

View File

@ -36,6 +36,8 @@ struct PaywallView: View {
let feature: AppFeature
let suggestedProduct: AppProduct?
// FIXME: #585, implement payments
var body: some View {
VStack {

View File

@ -37,6 +37,8 @@ public struct PurchaseButtonModifier: ViewModifier {
private let feature: AppFeature
private let suggestedProduct: AppProduct?
private let showsIfRestricted: Bool
@Binding
@ -46,18 +48,20 @@ public struct PurchaseButtonModifier: ViewModifier {
_ title: String,
label: String? = nil,
feature: AppFeature,
suggesting suggestedProduct: AppProduct?,
showsIfRestricted: Bool,
paywallReason: Binding<PaywallReason?>
) {
self.title = title
self.label = label
self.feature = feature
self.suggestedProduct = suggestedProduct
self.showsIfRestricted = showsIfRestricted
_paywallReason = paywallReason
}
public func body(content: Content) -> some View {
switch iapManager.paywallReason(forFeature: feature) {
switch iapManager.paywallReason(forFeature: feature, suggesting: suggestedProduct) {
case .purchase:
purchaseView
@ -85,7 +89,7 @@ private extension PurchaseButtonModifier {
var purchaseButton: some View {
Button(title) {
paywallReason = .purchase(feature)
paywallReason = .purchase(feature, suggestedProduct)
}
}
}

View File

@ -24,6 +24,7 @@
//
@testable import CommonLibrary
import CommonUtils
import Foundation
import XCTest
@ -259,3 +260,20 @@ extension IAPManagerTests {
XCTAssertTrue(sut.isEligible(for: .appleTV))
}
}
private extension IAPManager {
convenience init(
customUserLevel: AppUserLevel? = nil,
receiptReader: any AppReceiptReader,
unrestrictedFeatures: Set<AppFeature> = [],
productsAtBuild: BuildProducts<AppProduct>? = nil
) {
self.init(
customUserLevel: customUserLevel,
inAppHelper: MockAppProductHelper(),
receiptReader: receiptReader,
unrestrictedFeatures: unrestrictedFeatures,
productsAtBuild: productsAtBuild
)
}
}

View File

@ -37,9 +37,18 @@ extension AppContext {
let tunnelEnvironment: TunnelEnvironment = .shared
let registry: Registry = .shared
#if DEBUG
let inAppHelper = MockAppProductHelper()
let receiptReader = inAppHelper.receiptReader
#else
let inAppHelper = StoreKitHelper(identifiers: AppProduct.all)
let receiptReader = KvittoReceiptReader()
#endif
let iapManager = IAPManager(
customUserLevel: Configuration.IAPManager.customUserLevel,
receiptReader: KvittoReceiptReader(),
inAppHelper: inAppHelper,
receiptReader: receiptReader,
// FIXME: #662, omit unrestrictedFeatures on release!
unrestrictedFeatures: [.interactiveLogin],
productsAtBuild: Configuration.IAPManager.productsAtBuild