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 { public enum AppFeature: String, CaseIterable {
case appleTV case appleTV
case interactiveLogin
case networkSettings case networkSettings
case onDemand case onDemand
@ -37,6 +39,7 @@ public enum AppFeature: String, CaseIterable {
case siri case siri
public static let allCases: [AppFeature] = [ public static let allCases: [AppFeature] = [
.interactiveLogin,
.networkSettings, .networkSettings,
.onDemand, .onDemand,
.providers, .providers,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -45,12 +45,3 @@ extension EditableModule where Self: ModuleViewProviding {
.withMockEnvironment() .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) try? await Tunnel.mock.connect(with: .mock, processor: IAPManager.mock)
} }
.frame(width: 100, height: 100) .frame(width: 100, height: 100)
.environmentObject(Theme()) .withMockEnvironment()
.environmentObject(ConnectionObserver.mock)
} }
#Preview("On-Demand") { #Preview("On-Demand") {
@ -98,6 +97,5 @@ private extension ConnectionStatusView {
try? await Tunnel.mock.connect(with: profile, processor: IAPManager.mock) try? await Tunnel.mock.connect(with: profile, processor: IAPManager.mock)
} }
.frame(width: 100, height: 100) .frame(width: 100, height: 100)
.environmentObject(Theme()) .withMockEnvironment()
.environmentObject(ConnectionObserver.mock)
} }

View File

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

View File

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

View File

@ -56,5 +56,5 @@ struct NameSection: View {
) )
} }
.themeForm() .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() .themeForm()
.environmentObject(Theme()) .withMockEnvironment()
} }

View File

@ -109,10 +109,15 @@ private extension TunnelToggleButton {
} }
} }
if canConnect && profile.isInteractive { if canConnect && profile.isInteractive {
if iapManager.isEligible(for: .interactiveLogin) {
pp_log(.app, .notice, "Present interactive login")
interactiveManager.present(with: profile) { interactiveManager.present(with: profile) {
await perform(with: $0) await perform(with: $0)
} }
return return
} else {
pp_log(.app, .notice, "Suppress interactive login, not eligible")
}
} }
await perform(with: profile) 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)) 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 { func test_givenFullApp_thenIsFullVersion() async {
let reader = MockReceiptReader() let reader = MockReceiptReader()
let sut = IAPManager(customUserLevel: .fullVersion, receiptReader: reader) let sut = IAPManager(customUserLevel: .fullVersion, receiptReader: reader)

View File

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