Simulate in-app purchases (#818)
Integrate in-app helper into IAPManager and simulate purchases with an in-memory receipt.
This commit is contained in:
parent
9351ceeb6a
commit
d5ac785bb8
|
@ -63,6 +63,7 @@ struct OnDemandView: View, ModuleDraftEditing {
|
|||
.modifier(PurchaseButtonModifier(
|
||||
Strings.Modules.OnDemand.purchase,
|
||||
feature: .onDemand,
|
||||
suggesting: nil,
|
||||
showsIfRestricted: false,
|
||||
paywallReason: $paywallReason
|
||||
))
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -137,6 +137,7 @@ private extension ProviderContentModifier {
|
|||
.modifier(PurchaseButtonModifier(
|
||||
Strings.Providers.Picker.purchase,
|
||||
feature: .providers,
|
||||
suggesting: nil,
|
||||
showsIfRestricted: true,
|
||||
paywallReason: $paywallReason
|
||||
))
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -28,5 +28,5 @@ import Foundation
|
|||
public enum PaywallReason: Hashable {
|
||||
case restricted
|
||||
|
||||
case purchase(AppFeature)
|
||||
case purchase(AppFeature, AppProduct?)
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
.init(
|
||||
productIdentifier: $0.rawValue,
|
||||
cancellationDate: cancelledProducts.contains($0) ? Date() : nil,
|
||||
originalPurchaseDate: 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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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
|
||||
))
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -36,6 +36,8 @@ struct PaywallView: View {
|
|||
|
||||
let feature: AppFeature
|
||||
|
||||
let suggestedProduct: AppProduct?
|
||||
|
||||
// FIXME: #585, implement payments
|
||||
var body: some View {
|
||||
VStack {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue