Prepare interactive login for paywall (#663)

See #662
This commit is contained in:
Davide 2024-10-02 16:05:40 +02:00 committed by GitHub
parent dcdcec4da7
commit e8d5f2477b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 136 additions and 57 deletions

View File

@ -28,6 +28,8 @@ import Foundation
public enum AppFeature: String, CaseIterable {
case appleTV
case interactiveLogin
case networkSettings
case onDemand
@ -37,6 +39,7 @@ public enum AppFeature: String, CaseIterable {
case siri
public static let allCases: [AppFeature] = [
.interactiveLogin,
.networkSettings,
.onDemand,
.providers,

View File

@ -35,6 +35,8 @@ public final class IAPManager: ObservableObject {
private let receiptReader: any AppReceiptReader
private let unrestrictedFeatures: Set<AppFeature>
private let productsAtBuild: BuildProducts<AppProduct>?
private(set) var userLevel: AppUserLevel
@ -48,10 +50,12 @@ public final class IAPManager: ObservableObject {
public init(
customUserLevel: AppUserLevel? = nil,
receiptReader: any AppReceiptReader,
unrestrictedFeatures: Set<AppFeature> = [],
productsAtBuild: BuildProducts<AppProduct>? = nil
) {
self.customUserLevel = customUserLevel
self.receiptReader = receiptReader
self.unrestrictedFeatures = unrestrictedFeatures
self.productsAtBuild = productsAtBuild
userLevel = .undefined
purchasedProducts = []
@ -106,6 +110,10 @@ public final class IAPManager: ObservableObject {
eligibleFeatures = Set(userLevel.features)
}
unrestrictedFeatures.forEach {
eligibleFeatures.insert($0)
}
pp_log(.app, .notice, "Purchased products: \(purchasedProducts.map(\.rawValue))")
pp_log(.app, .notice, "Eligible features: \(eligibleFeatures)")
objectWillChange.send()

View File

@ -47,7 +47,7 @@ public final class KvittoReceiptReader: AppReceiptReader {
guard let receipt else {
let releaseUrl = url.deletingLastPathComponent().appendingPathComponent("receipt")
guard releaseUrl != url else {
#if !targetEnvironment(simulator)
#if !os(macOS) && !targetEnvironment(simulator)
assertionFailure("How can release URL be equal to sandbox URL in TestFlight?")
#endif
return nil

View File

@ -382,6 +382,10 @@ public enum Strings {
}
}
}
public enum Purchase {
/// Log in interactively
public static let interactive = Strings.tr("Localizable", "modules.openvpn.purchase.interactive", fallback: "Log in interactively")
}
}
public enum Wireguard {
/// Allowed IPs

View File

@ -179,6 +179,7 @@
"modules.on_demand.ethernet" = "Ethernet";
"modules.on_demand.ssid.add" = "Add SSID";
"modules.openvpn.purchase.interactive" = "Log in interactively";
"modules.openvpn.pull" = "Pull";
"modules.openvpn.redirect_gateway" = "Redirect gateway";
"modules.openvpn.credentials" = "Credentials";

View File

@ -97,5 +97,5 @@ extension AboutRouterView {
AboutRouterView(
tunnel: .mock
)
.environmentObject(Theme())
.withMockEnvironment()
}

View File

@ -161,6 +161,5 @@ private extension AppInlineCoordinator {
tunnel: .mock,
registry: Registry()
)
.environmentObject(Theme())
.environmentObject(ConnectionObserver.mock)
.withMockEnvironment()
}

View File

@ -149,6 +149,5 @@ extension AppModalCoordinator {
tunnel: .mock,
registry: Registry()
)
.environmentObject(Theme())
.environmentObject(ConnectionObserver.mock)
.withMockEnvironment()
}

View File

@ -118,5 +118,5 @@ private extension AppToolbar {
}
.frame(width: 600, height: 400)
}
.environmentObject(Theme())
.withMockEnvironment()
}

View File

@ -153,7 +153,6 @@ private struct PreviewView: View {
onEdit: { _ in }
)
}
.environmentObject(Theme())
.environmentObject(ConnectionObserver.mock)
.withMockEnvironment()
}
}

View File

@ -148,6 +148,5 @@ private extension ProfileGridView {
onEdit: { _ in }
)
.themeWindow(width: 600, height: 300)
.environmentObject(Theme())
.environmentObject(ConnectionObserver.mock)
.withMockEnvironment()
}

View File

@ -137,6 +137,5 @@ private extension ProfileListView {
errorHandler: .default(),
onEdit: { _ in }
)
.environmentObject(Theme())
.environmentObject(ConnectionObserver.mock)
.withMockEnvironment()
}

View File

@ -198,7 +198,5 @@ private extension DiagnosticsView {
.init(date: Date().addingTimeInterval(-600), url: URL(string: "http://three.com")!)
]
}
.environmentObject(Theme())
.environmentObject(ConnectionObserver.mock)
.environmentObject(IAPManager.mock)
.withMockEnvironment()
}

View File

@ -136,5 +136,5 @@ private extension IPView.RouteView {
}
return Preview()
.environmentObject(Theme())
.withMockEnvironment()
}

View File

@ -65,7 +65,7 @@ private struct OnDemandView: View {
var body: some View {
Group {
enabledSection
restrictedSection
restrictedArea
}
.asModuleView(with: editor, draft: draft)
.modifier(PaywallModifier(reason: $paywallReason))
@ -86,11 +86,11 @@ private extension OnDemandView {
}
@ViewBuilder
var restrictedSection: some View {
var restrictedArea: some View {
switch iapManager.paywallReason(forFeature: .onDemand) {
case .purchase(let feature):
case .purchase(let appFeature):
Button(Strings.Modules.OnDemand.purchase) {
paywallReason = .purchase(feature)
paywallReason = .purchase(appFeature)
}
case .restricted:

View File

@ -29,6 +29,9 @@ import SwiftUI
extension OpenVPNView {
struct CredentialsView: View {
@EnvironmentObject
private var iapManager: IAPManager
@Binding
var isInteractive: Bool
@ -43,11 +46,12 @@ extension OpenVPNView {
@State
private var otp = ""
@State
private var paywallReason: PaywallReason?
var body: some View {
Form {
if !isAuthenticating {
interactiveSection
}
restrictedArea
inputSection
}
.themeAnimation(on: isInteractive, category: .modules)
@ -58,7 +62,7 @@ extension OpenVPNView {
builder = credentials?.builder() ?? OpenVPN.Credentials.Builder()
}
.onChange(of: builder) {
if isAuthenticating {
if isEligibleForInteractiveLogin, isAuthenticating {
credentials = $0.buildForAuthentication(otp: otp)
} else {
credentials = $0.build()
@ -67,15 +71,38 @@ extension OpenVPNView {
.onChange(of: otp) {
credentials = builder.buildForAuthentication(otp: $0)
}
.modifier(PaywallModifier(reason: $paywallReason))
}
}
}
private extension OpenVPNView.CredentialsView {
var isEligibleForInteractiveLogin: Bool {
iapManager.isEligible(for: .interactiveLogin)
}
var otpMethods: [OpenVPN.Credentials.OTPMethod] {
[.none, .append, .encode]
}
@ViewBuilder
var restrictedArea: some View {
switch iapManager.paywallReason(forFeature: .interactiveLogin) {
case .purchase(let appFeature):
Button(Strings.Modules.Openvpn.Purchase.interactive) {
paywallReason = .purchase(appFeature)
}
case .restricted:
EmptyView()
default:
if !isAuthenticating {
interactiveSection
}
}
}
var interactiveSection: some View {
Group {
Toggle(Strings.Modules.Openvpn.Credentials.interactive, isOn: $isInteractive)
@ -108,7 +135,7 @@ private extension OpenVPNView.CredentialsView {
ThemeSecureField(title: Strings.Global.password, text: $builder.password, placeholder: Strings.Placeholders.secret)
.textContentType(.password)
if isAuthenticating && builder.otpMethod != .none {
if isEligibleForInteractiveLogin, isAuthenticating && builder.otpMethod != .none {
ThemeSecureField(title: Strings.Unlocalized.otp, text: $otp, placeholder: Strings.Placeholders.secret)
.textContentType(.oneTimeCode)
}
@ -137,6 +164,6 @@ private extension OpenVPNView.CredentialsView {
isInteractive: $isInteractive,
credentials: $credentials
)
.environmentObject(Theme())
.withMockEnvironment()
}
}

View File

@ -64,5 +64,5 @@ private extension ModuleDetailView {
moduleId: Profile.mock.modules.first?.id,
moduleViewFactory: DefaultModuleViewFactory()
)
.environmentObject(Theme())
.withMockEnvironment()
}

View File

@ -145,5 +145,5 @@ private extension ProfileCoordinator {
path: .constant(NavigationPath()),
onDismiss: {}
)
.environmentObject(Theme())
.withMockEnvironment()
}

View File

@ -170,7 +170,7 @@ private extension ProfileEditView {
path: .constant(NavigationPath())
)
}
.environmentObject(Theme())
.withMockEnvironment()
}
#endif

View File

@ -141,7 +141,7 @@ private extension ModuleListView {
selectedModuleId: .constant(nil),
malformedModuleIds: .constant([])
)
.environmentObject(Theme())
.withMockEnvironment()
}
#endif

View File

@ -52,7 +52,7 @@ struct ProfileGeneralView: View {
ProfileGeneralView(
profileEditor: ProfileEditor()
)
.environmentObject(Theme())
.withMockEnvironment()
}
#endif

View File

@ -121,7 +121,7 @@ private extension ProfileSplitView {
profileEditor: ProfileEditor(profile: .newProfile()),
moduleViewFactory: DefaultModuleViewFactory()
)
.environmentObject(Theme())
.withMockEnvironment()
}
#endif

View File

@ -45,12 +45,3 @@ extension EditableModule where Self: ModuleViewProviding {
.withMockEnvironment()
}
}
@MainActor
private extension View {
func withMockEnvironment() -> some View {
environmentObject(Theme())
.environmentObject(IAPManager.mock)
.environmentObject(ConnectionObserver.mock)
}
}

View File

@ -78,8 +78,7 @@ private extension ConnectionStatusView {
try? await Tunnel.mock.connect(with: .mock, processor: IAPManager.mock)
}
.frame(width: 100, height: 100)
.environmentObject(Theme())
.environmentObject(ConnectionObserver.mock)
.withMockEnvironment()
}
#Preview("On-Demand") {
@ -98,6 +97,5 @@ private extension ConnectionStatusView {
try? await Tunnel.mock.connect(with: profile, processor: IAPManager.mock)
}
.frame(width: 100, height: 100)
.environmentObject(Theme())
.environmentObject(ConnectionObserver.mock)
.withMockEnvironment()
}

View File

@ -195,8 +195,7 @@ private struct CardModifier: ViewModifier {
}
}
.themeForm()
.environmentObject(Theme())
.environmentObject(ConnectionObserver.mock)
.withMockEnvironment()
}
#Preview("Grid") {
@ -209,8 +208,7 @@ private struct CardModifier: ViewModifier {
}
.padding()
}
.environmentObject(Theme())
.environmentObject(ConnectionObserver.mock)
.withMockEnvironment()
}
private struct HeaderView: View {

View File

@ -42,5 +42,5 @@ struct LogoView: View {
#Preview {
LogoView()
.environmentObject(Theme())
.withMockEnvironment()
}

View File

@ -56,5 +56,5 @@ struct NameSection: View {
)
}
.themeForm()
.environmentObject(Theme())
.withMockEnvironment()
}

View File

@ -125,5 +125,5 @@ private extension ProfileContextMenu {
)
}
}
.environmentObject(Theme())
.withMockEnvironment()
}

View File

@ -53,5 +53,5 @@ struct StorageSection: View {
)
}
.themeForm()
.environmentObject(Theme())
.withMockEnvironment()
}

View File

@ -109,10 +109,15 @@ private extension TunnelToggleButton {
}
}
if canConnect && profile.isInteractive {
interactiveManager.present(with: profile) {
await perform(with: $0)
if iapManager.isEligible(for: .interactiveLogin) {
pp_log(.app, .notice, "Present interactive login")
interactiveManager.present(with: profile) {
await perform(with: $0)
}
return
} else {
pp_log(.app, .notice, "Suppress interactive login, not eligible")
}
return
}
await perform(with: profile)
}

View File

@ -0,0 +1,35 @@
//
// View+Mock.swift
// Passepartout
//
// Created by Davide De Rosa on 10/2/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 SwiftUI
@MainActor
extension View {
func withMockEnvironment() -> some View {
environmentObject(Theme())
.environmentObject(IAPManager.mock)
.environmentObject(ConnectionObserver.mock)
}
}

View File

@ -267,6 +267,20 @@ extension IAPManagerTests {
XCTAssertFalse(sut.isEligible(for: AppFeature.allCases))
}
func test_givenBetaApp_thenIsEligibleForUnrestrictedFeature() async {
let reader = MockReceiptReader()
let sut = IAPManager(customUserLevel: .beta, receiptReader: reader, unrestrictedFeatures: [.onDemand])
await sut.reloadReceipt()
AppFeature.allCases.forEach {
if $0 == .onDemand {
XCTAssertTrue(sut.isEligible(for: $0))
} else {
XCTAssertFalse(sut.isEligible(for: $0))
}
}
}
func test_givenFullApp_thenIsFullVersion() async {
let reader = MockReceiptReader()
let sut = IAPManager(customUserLevel: .fullVersion, receiptReader: reader)

View File

@ -43,6 +43,8 @@ extension IAPManager {
static let shared = IAPManager(
customUserLevel: customUserLevel,
receiptReader: KvittoReceiptReader(),
// FIXME: #662, omit unrestrictedFeatures on release!
unrestrictedFeatures: [.interactiveLogin],
productsAtBuild: productsAtBuild
)