diff --git a/Library/Sources/AppUIMain/Views/App/ProfileRowView.swift b/Library/Sources/AppUIMain/Views/App/ProfileRowView.swift index 02a093403..e3fd90768 100644 --- a/Library/Sources/AppUIMain/Views/App/ProfileRowView.swift +++ b/Library/Sources/AppUIMain/Views/App/ProfileRowView.swift @@ -123,7 +123,7 @@ private struct MarkerView: View { .opaque(requiredFeatures == nil && (profileId == nextProfileId || profileId == tunnel.currentProfile?.id)) if let requiredFeatures { - PurchaseRequiredButton(features: requiredFeatures, paywallReason: .constant(nil)) + PurchaseRequiredButton(features: requiredFeatures) } } .frame(width: 24) diff --git a/Library/Sources/AppUIMain/Views/Diagnostics/DiagnosticsView.swift b/Library/Sources/AppUIMain/Views/Diagnostics/DiagnosticsView.swift index df76f92bc..0eed0807b 100644 --- a/Library/Sources/AppUIMain/Views/Diagnostics/DiagnosticsView.swift +++ b/Library/Sources/AppUIMain/Views/Diagnostics/DiagnosticsView.swift @@ -80,7 +80,7 @@ struct DiagnosticsView: View { liveLogSection openVPNSection tunnelLogsSection - if iapManager.isEligibleForFeedback() { + if iapManager.isEligibleForFeedback { reportIssueSection } } diff --git a/Library/Sources/AppUIMain/Views/Modules/OnDemandView.swift b/Library/Sources/AppUIMain/Views/Modules/OnDemandView.swift index eb3743fc9..319c58f56 100644 --- a/Library/Sources/AppUIMain/Views/Modules/OnDemandView.swift +++ b/Library/Sources/AppUIMain/Views/Modules/OnDemandView.swift @@ -40,9 +40,6 @@ struct OnDemandView: View, ModuleDraftEditing { private let wifi: Wifi - @State - private var paywallReason: PaywallReason? - init( module: OnDemandModule.Builder, parameters: ModuleViewParameters, @@ -59,7 +56,6 @@ struct OnDemandView: View, ModuleDraftEditing { rulesArea } .moduleView(editor: editor, draft: draft.wrappedValue) - .modifier(PaywallModifier(reason: $paywallReason)) } } @@ -95,7 +91,7 @@ private extension OnDemandView { } label: { HStack { Text(Strings.Modules.OnDemand.policy) - PurchaseRequiredButton(for: module, paywallReason: $paywallReason) + PurchaseRequiredButton(for: module) } } .themeSectionWithSingleRow(footer: policyFooterDescription) diff --git a/Library/Sources/AppUIMain/Views/Profile/ProfileCoordinator.swift b/Library/Sources/AppUIMain/Views/Profile/ProfileCoordinator.swift index d36f1c549..973e51962 100644 --- a/Library/Sources/AppUIMain/Views/Profile/ProfileCoordinator.swift +++ b/Library/Sources/AppUIMain/Views/Profile/ProfileCoordinator.swift @@ -88,7 +88,6 @@ private extension ProfileCoordinator { initialModuleId: initialModuleId, moduleViewFactory: moduleViewFactory, path: $path, - paywallReason: $paywallReason, flow: .init( onNewModule: onNewModule, onCommitEditing: onCommitEditing, @@ -102,7 +101,6 @@ private extension ProfileCoordinator { profileEditor: profileEditor, initialModuleId: initialModuleId, moduleViewFactory: moduleViewFactory, - paywallReason: $paywallReason, flow: .init( onNewModule: onNewModule, onCommitEditing: onCommitEditing, @@ -138,7 +136,7 @@ private extension ProfileCoordinator { func onCommitEditingStandard() async throws { let savedProfile = try await profileEditor.save(to: profileManager, preferencesManager: preferencesManager) do { - try iapManager.verify(savedProfile) + try iapManager.verify(savedProfile, isShared: profileEditor.isShared) } catch AppError.ineligibleProfile(let requiredFeatures) { paywallReason = .init(requiredFeatures, needsConfirmation: true) return @@ -149,7 +147,7 @@ private extension ProfileCoordinator { // restricted: verify before saving func onCommitEditingRestricted() async throws { do { - try iapManager.verify(profileEditor.activeModules) + try iapManager.verify(profileEditor.activeModules, isShared: profileEditor.isShared) } catch AppError.ineligibleProfile(let requiredFeatures) { paywallReason = .init(requiredFeatures) return diff --git a/Library/Sources/AppUIMain/Views/Profile/StorageSection.swift b/Library/Sources/AppUIMain/Views/Profile/StorageSection.swift index 39d828177..1fbebb58e 100644 --- a/Library/Sources/AppUIMain/Views/Profile/StorageSection.swift +++ b/Library/Sources/AppUIMain/Views/Profile/StorageSection.swift @@ -34,9 +34,6 @@ struct StorageSection: View { @ObservedObject var profileEditor: ProfileEditor - @Binding - var paywallReason: PaywallReason? - var body: some View { debugChanges() return Group { @@ -44,7 +41,6 @@ struct StorageSection: View { .themeRowWithSubtitle(sharingDescription) tvToggle .themeRowWithSubtitle(tvDescription) - purchaseButton } .themeSection(header: header, footer: footer) } @@ -52,41 +48,26 @@ struct StorageSection: View { private extension StorageSection { var sharingToggle: some View { - Toggle(Strings.Modules.General.Rows.shared, isOn: $profileEditor.isShared) - .disabled(!iapManager.isEligible(for: .sharing)) + Toggle(isOn: $profileEditor.isShared) { + HStack { + Text(Strings.Modules.General.Rows.shared) + PurchaseRequiredButton(features: profileEditor.isShared ? [.sharing] : []) + } + } } var tvToggle: some View { - Toggle(Strings.Modules.General.Rows.appletv(Strings.Unlocalized.appleTV), isOn: $profileEditor.isAvailableForTV) - .disabled(!iapManager.isEligible(for: .appleTV) || !profileEditor.isShared) - } - - @ViewBuilder - var purchaseButton: some View { - if !iapManager.isEligible(for: .sharing) { - purchaseSharingButton - } else if !iapManager.isEligible(for: .appleTV) { - purchaseTVButton + Toggle(isOn: $profileEditor.isAvailableForTV) { + HStack { + Text(Strings.Modules.General.Rows.appletv(Strings.Unlocalized.appleTV)) + PurchaseRequiredButton(features: profileEditor.isAvailableForTV ? [.appleTV] : []) + } } + .disabled(!profileEditor.isShared) } +} - var purchaseSharingButton: some View { - PurchaseRequiredButton( - Strings.Modules.General.Rows.Shared.purchase, - features: [.sharing], - paywallReason: $paywallReason - ) - } - - var purchaseTVButton: some View { - PurchaseRequiredButton( - Strings.Modules.General.Rows.Appletv.purchase, - features: [.appleTV], - suggestedProduct: .Features.appleTV, - paywallReason: $paywallReason - ) - } - +private extension StorageSection { var header: String { Strings.Modules.General.Sections.Storage.header(Strings.Unlocalized.iCloud) } @@ -119,10 +100,7 @@ private extension StorageSection { #Preview { Form { - StorageSection( - profileEditor: ProfileEditor(), - paywallReason: .constant(nil) - ) + StorageSection(profileEditor: ProfileEditor()) } .themeForm() .withMockEnvironment() diff --git a/Library/Sources/AppUIMain/Views/Profile/iOS/ProfileEditView+iOS.swift b/Library/Sources/AppUIMain/Views/Profile/iOS/ProfileEditView+iOS.swift index 380fb64ea..be59a0aa5 100644 --- a/Library/Sources/AppUIMain/Views/Profile/iOS/ProfileEditView+iOS.swift +++ b/Library/Sources/AppUIMain/Views/Profile/iOS/ProfileEditView+iOS.swift @@ -42,9 +42,6 @@ struct ProfileEditView: View, Routable { @Binding var path: NavigationPath - @Binding - var paywallReason: PaywallReason? - var flow: ProfileCoordinator.Flow? @State @@ -57,10 +54,7 @@ struct ProfileEditView: View, Routable { name: $profileEditor.profile.name ) modulesSection - StorageSection( - profileEditor: profileEditor, - paywallReason: $paywallReason - ) + StorageSection(profileEditor: profileEditor) UUIDSection(uuid: profileEditor.profile.id) } .toolbar(content: toolbarContent) @@ -121,10 +115,7 @@ private extension ProfileEditView { if errorModuleIds.contains(module.id) { ThemeImage(.warning) } else if profileEditor.isActiveModule(withId: module.id) { - PurchaseRequiredButton( - for: module as? AppFeatureRequiring, - paywallReason: $paywallReason - ) + PurchaseRequiredButton(for: module as? AppFeatureRequiring) } Spacer() } @@ -193,8 +184,7 @@ private extension ProfileEditView { profileEditor: ProfileEditor(profile: .newMockProfile()), initialModuleId: nil, moduleViewFactory: DefaultModuleViewFactory(registry: Registry()), - path: .constant(NavigationPath()), - paywallReason: .constant(nil) + path: .constant(NavigationPath()) ) } .withMockEnvironment() diff --git a/Library/Sources/AppUIMain/Views/Profile/macOS/ModuleListView+macOS.swift b/Library/Sources/AppUIMain/Views/Profile/macOS/ModuleListView+macOS.swift index d46ca796b..d1e933dba 100644 --- a/Library/Sources/AppUIMain/Views/Profile/macOS/ModuleListView+macOS.swift +++ b/Library/Sources/AppUIMain/Views/Profile/macOS/ModuleListView+macOS.swift @@ -46,9 +46,6 @@ struct ModuleListView: View, Routable { @Binding var errorModuleIds: Set - @Binding - var paywallReason: PaywallReason? - var flow: ProfileCoordinator.Flow? var body: some View { @@ -81,10 +78,7 @@ private extension ModuleListView { if errorModuleIds.contains(module.id) { ThemeImage(.warning) } else if profileEditor.isActiveModule(withId: module.id) { - PurchaseRequiredButton( - for: module as? AppFeatureRequiring, - paywallReason: $paywallReason - ) + PurchaseRequiredButton(for: module as? AppFeatureRequiring) } Spacer() if !isUITesting { @@ -156,8 +150,7 @@ private extension ModuleListView { ModuleListView( profileEditor: ProfileEditor(profile: .forPreviews), selectedModuleId: .constant(nil), - errorModuleIds: .constant([]), - paywallReason: .constant(nil) + errorModuleIds: .constant([]) ) .withMockEnvironment() } diff --git a/Library/Sources/AppUIMain/Views/Profile/macOS/ProfileGeneralView+macOS.swift b/Library/Sources/AppUIMain/Views/Profile/macOS/ProfileGeneralView+macOS.swift index 1b435ba9d..968edaf3a 100644 --- a/Library/Sources/AppUIMain/Views/Profile/macOS/ProfileGeneralView+macOS.swift +++ b/Library/Sources/AppUIMain/Views/Profile/macOS/ProfileGeneralView+macOS.swift @@ -33,18 +33,10 @@ struct ProfileGeneralView: View { @ObservedObject var profileEditor: ProfileEditor - @Binding - var paywallReason: PaywallReason? - var body: some View { Form { - ProfileNameSection( - name: $profileEditor.profile.name - ) - StorageSection( - profileEditor: profileEditor, - paywallReason: $paywallReason - ) + ProfileNameSection(name: $profileEditor.profile.name) + StorageSection(profileEditor: profileEditor) UUIDSection(uuid: profileEditor.profile.id) } .themeForm() @@ -52,11 +44,8 @@ struct ProfileGeneralView: View { } #Preview { - ProfileGeneralView( - profileEditor: ProfileEditor(), - paywallReason: .constant(nil) - ) - .withMockEnvironment() + ProfileGeneralView(profileEditor: ProfileEditor()) + .withMockEnvironment() } #endif diff --git a/Library/Sources/AppUIMain/Views/Profile/macOS/ProfileSplitView+macOS.swift b/Library/Sources/AppUIMain/Views/Profile/macOS/ProfileSplitView+macOS.swift index ccd2119c7..861ae3b72 100644 --- a/Library/Sources/AppUIMain/Views/Profile/macOS/ProfileSplitView+macOS.swift +++ b/Library/Sources/AppUIMain/Views/Profile/macOS/ProfileSplitView+macOS.swift @@ -37,9 +37,6 @@ struct ProfileSplitView: View, Routable { let moduleViewFactory: any ModuleViewFactory - @Binding - var paywallReason: PaywallReason? - var flow: ProfileCoordinator.Flow? @State @@ -58,7 +55,6 @@ struct ProfileSplitView: View, Routable { profileEditor: profileEditor, selectedModuleId: $selectedModuleId, errorModuleIds: $errorModuleIds, - paywallReason: $paywallReason, flow: flow ) .navigationSplitViewColumnWidth(200) @@ -123,10 +119,7 @@ private extension ProfileSplitView { func detailView(for detail: Detail) -> some View { switch detail { case .general: - ProfileGeneralView( - profileEditor: profileEditor, - paywallReason: $paywallReason - ) + ProfileGeneralView(profileEditor: profileEditor) case .module(let id): ModuleDetailView( @@ -142,8 +135,7 @@ private extension ProfileSplitView { ProfileSplitView( profileEditor: ProfileEditor(profile: .newMockProfile()), initialModuleId: nil, - moduleViewFactory: DefaultModuleViewFactory(registry: Registry()), - paywallReason: .constant(nil) + moduleViewFactory: DefaultModuleViewFactory(registry: Registry()) ) .withMockEnvironment() } diff --git a/Library/Sources/AppUIMain/Views/Providers/ProviderContentModifier.swift b/Library/Sources/AppUIMain/Views/Providers/ProviderContentModifier.swift index f88923b14..62afc3122 100644 --- a/Library/Sources/AppUIMain/Views/Providers/ProviderContentModifier.swift +++ b/Library/Sources/AppUIMain/Views/Providers/ProviderContentModifier.swift @@ -46,9 +46,6 @@ struct ProviderContentModifier: ViewModifier where Entity: let entityType: Entity.Type - @Binding - var paywallReason: PaywallReason? - @ViewBuilder let providerRows: ProviderRows @@ -120,8 +117,7 @@ private extension ProviderContentModifier { providers: supportedProviders, providerId: $providerId, isRequired: true, - isLoading: providerManager.isLoading, - paywallReason: $paywallReason + isLoading: providerManager.isLoading ) } } @@ -222,7 +218,6 @@ private extension ProviderContentModifier { providerId: .constant(.hideme), providerPreferences: nil, entityType: VPNEntity.self, - paywallReason: .constant(nil), providerRows: {}, onSelectProvider: { _, _, _ in } )) diff --git a/Library/Sources/AppUIMain/Views/Providers/ProviderPicker.swift b/Library/Sources/AppUIMain/Views/Providers/ProviderPicker.swift index 0ef22a669..4634322a3 100644 --- a/Library/Sources/AppUIMain/Views/Providers/ProviderPicker.swift +++ b/Library/Sources/AppUIMain/Views/Providers/ProviderPicker.swift @@ -37,9 +37,6 @@ struct ProviderPicker: View { let isLoading: Bool - @Binding - var paywallReason: PaywallReason? - var body: some View { Picker(selection: $providerId) { if !providers.isEmpty { @@ -56,7 +53,7 @@ struct ProviderPicker: View { } label: { HStack { Text(Strings.Global.Nouns.provider) - PurchaseRequiredButton(for: providerId, paywallReason: $paywallReason) + PurchaseRequiredButton(for: providerId) } } .disabled(isLoading || providers.isEmpty) diff --git a/Library/Sources/AppUIMain/Views/VPN/VPNProviderContentModifier.swift b/Library/Sources/AppUIMain/Views/VPN/VPNProviderContentModifier.swift index 36c85a6f2..8e9aeb967 100644 --- a/Library/Sources/AppUIMain/Views/VPN/VPNProviderContentModifier.swift +++ b/Library/Sources/AppUIMain/Views/VPN/VPNProviderContentModifier.swift @@ -55,7 +55,6 @@ struct VPNProviderContentModifier: ViewModifier whe providerId: $providerId, providerPreferences: providerPreferences, entityType: VPNEntity.self, - paywallReason: $paywallReason, providerRows: { providerEntityRow providerRows diff --git a/Library/Sources/CommonIAP/Domain/AppProduct+Features.swift b/Library/Sources/CommonIAP/Domain/AppProduct+Features.swift index 698e55a70..2ff30ace7 100644 --- a/Library/Sources/CommonIAP/Domain/AppProduct+Features.swift +++ b/Library/Sources/CommonIAP/Domain/AppProduct+Features.swift @@ -37,10 +37,8 @@ extension AppProduct { public enum Full { static let all: [AppProduct] = [ - .Full.allPlatforms, - .Full.iOS, - .Full.macOS, - .Full.OneTime.lifetime, + .Full.OneTime.full, + .Full.OneTime.fullTV, .Full.Recurring.monthly, .Full.Recurring.yearly ] @@ -65,7 +63,12 @@ extension AppProduct.Features { extension AppProduct.Full { public enum OneTime { - public static let lifetime = AppProduct(featureId: "full.lifetime") + + // iOS/macOS + public static let full = AppProduct(featureId: "full_multi_version") + + // iOS/macOS + tvOS + public static let fullTV = AppProduct(featureId: "full.lifetime") } public enum Recurring { @@ -85,10 +88,11 @@ extension AppProduct.Features { public static let trustedNetworks = AppProduct(featureId: "trusted_networks") } -extension AppProduct.Full { - public static let allPlatforms = AppProduct(featureId: "full_multi_version") +extension AppProduct.Full.OneTime { + @available(*, deprecated) public static let iOS = AppProduct(featureId: "full_version") + @available(*, deprecated) public static let macOS = AppProduct(featureId: "full_mac_version") } diff --git a/Library/Sources/CommonIAP/Domain/AppProduct.swift b/Library/Sources/CommonIAP/Domain/AppProduct.swift index 9cd5f9423..7fbdc144a 100644 --- a/Library/Sources/CommonIAP/Domain/AppProduct.swift +++ b/Library/Sources/CommonIAP/Domain/AppProduct.swift @@ -44,7 +44,7 @@ extension AppProduct { var isLegacyPlatformVersion: Bool { switch self { - case .Full.iOS, .Full.macOS: + case .Full.OneTime.iOS, .Full.OneTime.macOS: return true default: diff --git a/Library/Sources/CommonIAP/Domain/AppUserLevel.swift b/Library/Sources/CommonIAP/Domain/AppUserLevel.swift index b2c6d4df0..58817423a 100644 --- a/Library/Sources/CommonIAP/Domain/AppUserLevel.swift +++ b/Library/Sources/CommonIAP/Domain/AppUserLevel.swift @@ -32,22 +32,12 @@ public enum AppUserLevel: Int, Sendable { case beta = 1 - case fullV2 = 2 + case full = 2 - case subscriber = 3 + case fullTV = 3 } extension AppUserLevel { - public var isFullVersion: Bool { - switch self { - case .fullV2, .subscriber: - return true - - default: - return false - } - } - public var isRestricted: Bool { switch self { case .undefined, .beta: diff --git a/Library/Sources/CommonLibrary/Business/IAPManager.swift b/Library/Sources/CommonLibrary/Business/IAPManager.swift index 5569354e6..efd83c975 100644 --- a/Library/Sources/CommonLibrary/Business/IAPManager.swift +++ b/Library/Sources/CommonLibrary/Business/IAPManager.swift @@ -136,15 +136,15 @@ extension IAPManager { return features.allSatisfy(eligibleFeatures.contains) } - public func isEligibleForFeedback() -> Bool { + public var isEligibleForFeedback: Bool { #if os(tvOS) false #else - userLevel == .beta || isPayingUser() + userLevel == .beta || isPayingUser #endif } - public func isPayingUser() -> Bool { + public var isPayingUser: Bool { !purchasedProducts.isEmpty } } diff --git a/Library/Sources/CommonLibrary/IAP/AppFeature+Full.swift b/Library/Sources/CommonLibrary/IAP/AppFeature+Full.swift index 24ae0d264..073e6ad59 100644 --- a/Library/Sources/CommonLibrary/IAP/AppFeature+Full.swift +++ b/Library/Sources/CommonLibrary/IAP/AppFeature+Full.swift @@ -26,12 +26,9 @@ import Foundation extension AppFeature { - public static let fullV2Features: [AppFeature] = [ - .dns, - .httpProxy, - .onDemand, - .providers, - .routing, - .sharing - ] + public static let fullFeatures = AppFeature.allCases.filter { + $0 != .appleTV + } + + public static let fullTVFeatures = AppFeature.allCases } diff --git a/Library/Sources/CommonLibrary/IAP/AppFeatureProviding+Levels.swift b/Library/Sources/CommonLibrary/IAP/AppFeatureProviding+Levels.swift index 8964a0b10..e8ae88b4f 100644 --- a/Library/Sources/CommonLibrary/IAP/AppFeatureProviding+Levels.swift +++ b/Library/Sources/CommonLibrary/IAP/AppFeatureProviding+Levels.swift @@ -35,11 +35,11 @@ extension AppUserLevel: AppFeatureProviding { .sharing ] - case .fullV2: - return AppFeature.fullV2Features + case .full: + return AppFeature.fullFeatures - case .subscriber: - return AppFeature.allCases + case .fullTV: + return AppFeature.fullTVFeatures default: return [] diff --git a/Library/Sources/CommonLibrary/IAP/AppFeatureProviding+Products.swift b/Library/Sources/CommonLibrary/IAP/AppFeatureProviding+Products.swift index 17af68810..a943b0746 100644 --- a/Library/Sources/CommonLibrary/IAP/AppFeatureProviding+Products.swift +++ b/Library/Sources/CommonLibrary/IAP/AppFeatureProviding+Products.swift @@ -31,40 +31,40 @@ extension AppProduct: AppFeatureProviding { // MARK: Current + case .Full.OneTime.full: + return AppFeature.fullFeatures + + case .Full.OneTime.fullTV, .Full.Recurring.monthly, .Full.Recurring.yearly: + return AppFeature.fullTVFeatures + case .Features.appleTV: return [.appleTV, .sharing] - case .Full.OneTime.lifetime, .Full.Recurring.monthly, .Full.Recurring.yearly: - return AppFeature.allCases - // MARK: Discontinued - case .Features.allProviders: - return [.providers] - - case .Features.networkSettings: - return [.dns, .httpProxy, .routing] - - case .Features.trustedNetworks: - return [.onDemand] - - case .Full.allPlatforms: - return AppFeature.fullV2Features - - case .Full.iOS: + case .Full.OneTime.iOS: #if os(iOS) - return AppFeature.fullV2Features + return AppFeature.fullFeatures #else return [] #endif - case .Full.macOS: + case .Full.OneTime.macOS: #if os(macOS) - return AppFeature.fullV2Features + return AppFeature.fullFeatures #else return [] #endif + case .Features.allProviders: + return [.providers] + + case .Features.networkSettings: + return [.dns, .httpProxy, .routing] + + case .Features.trustedNetworks: + return [.onDemand] + default: return [] } diff --git a/Library/Sources/CommonLibrary/IAP/AppFeatureRequiring+Profile.swift b/Library/Sources/CommonLibrary/IAP/AppFeatureRequiring+Profile.swift index 608ac00d3..23dc9f91a 100644 --- a/Library/Sources/CommonLibrary/IAP/AppFeatureRequiring+Profile.swift +++ b/Library/Sources/CommonLibrary/IAP/AppFeatureRequiring+Profile.swift @@ -34,7 +34,11 @@ extension Profile: AppFeatureRequiring { } return builder } - return builders.features + var requirements = builders.features + if attributes.isAvailableForTV == true { + requirements.insert(.appleTV) + } + return requirements } } diff --git a/Library/Sources/CommonLibrary/IAP/IAPManager+Suggestions.swift b/Library/Sources/CommonLibrary/IAP/IAPManager+Suggestions.swift new file mode 100644 index 000000000..5657dd732 --- /dev/null +++ b/Library/Sources/CommonLibrary/IAP/IAPManager+Suggestions.swift @@ -0,0 +1,69 @@ +// +// IAPManager+Suggestions.swift +// Passepartout +// +// Created by Davide De Rosa on 12/16/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 . +// + +import CommonIAP +import CommonUtils +import Foundation +import PassepartoutKit + +extension IAPManager { + public var isFullVersionPurchaser: Bool { + purchasedProducts.contains(.Full.OneTime.full) || purchasedProducts.contains(.Full.OneTime.fullTV) || (purchasedProducts.contains(.Full.OneTime.iOS) && purchasedProducts.contains(.Full.OneTime.macOS)) + } + + public func suggestedProducts(for features: Set) -> [AppProduct] { + guard !features.isEmpty else { + return [] + } + guard !eligibleFeatures.isSuperset(of: features) else { + return [] + } + + var list: [AppProduct] = [] + let requiredFeatures = features.subtracting(eligibleFeatures) + + if isFullVersionPurchaser { + if requiredFeatures == [.appleTV] { + list.append(.Features.appleTV) + } else { + assertionFailure("Full version purchaser requiring other than [.appleTV]") + } + } else { // !isFullVersionPurchaser + if requiredFeatures == [.appleTV] { + list.append(.Features.appleTV) + list.append(.Full.OneTime.fullTV) + } else if requiredFeatures.contains(.appleTV) { + list.append(.Full.OneTime.fullTV) + } else { + list.append(.Full.OneTime.full) + if !eligibleFeatures.contains(.appleTV) { + list.append(.Full.OneTime.fullTV) + } + } + } + + return list + } +} diff --git a/Library/Sources/CommonLibrary/IAP/IAPManager+Verify.swift b/Library/Sources/CommonLibrary/IAP/IAPManager+Verify.swift index afdbede06..0ecfe76de 100644 --- a/Library/Sources/CommonLibrary/IAP/IAPManager+Verify.swift +++ b/Library/Sources/CommonLibrary/IAP/IAPManager+Verify.swift @@ -27,12 +27,20 @@ import Foundation import PassepartoutKit extension IAPManager { - public func verify(_ profile: Profile) throws { - try verify(profile.features) + public func verify(_ profile: Profile, isShared: Bool = false) throws { + var features = profile.features + if isShared { + features.insert(.sharing) + } + try verify(features) } - public func verify(_ modulesBuilders: [any ModuleBuilder]) throws { - try verify(modulesBuilders.features) + public func verify(_ modulesBuilders: [any ModuleBuilder], isShared: Bool = false) throws { + var features = modulesBuilders.features + if isShared { + features.insert(.sharing) + } + try verify(features) } public func verify(_ features: Set) throws { diff --git a/Library/Sources/UILibrary/L10n/SwiftGen+Strings.swift b/Library/Sources/UILibrary/L10n/SwiftGen+Strings.swift index 55ac8e64f..b348fe344 100644 --- a/Library/Sources/UILibrary/L10n/SwiftGen+Strings.swift +++ b/Library/Sources/UILibrary/L10n/SwiftGen+Strings.swift @@ -258,6 +258,8 @@ public enum Strings { public static let enabled = Strings.tr("Localizable", "global.nouns.enabled", fallback: "Enabled") /// Endpoint public static let endpoint = Strings.tr("Localizable", "global.nouns.endpoint", fallback: "Endpoint") + /// Features + public static let features = Strings.tr("Localizable", "global.nouns.features", fallback: "Features") /// Filters public static let filters = Strings.tr("Localizable", "global.nouns.filters", fallback: "Filters") /// Folder @@ -316,6 +318,8 @@ public enum Strings { public static let provider = Strings.tr("Localizable", "global.nouns.provider", fallback: "Provider") /// Public key public static let publicKey = Strings.tr("Localizable", "global.nouns.public_key", fallback: "Public key") + /// Purchases + public static let purchases = Strings.tr("Localizable", "global.nouns.purchases", fallback: "Purchases") /// Region public static let region = Strings.tr("Localizable", "global.nouns.region", fallback: "Region") /// Route @@ -853,14 +857,6 @@ public enum Strings { /// First download public static let header = Strings.tr("Localizable", "views.purchased.sections.download.header", fallback: "First download") } - public enum Features { - /// Features - public static let header = Strings.tr("Localizable", "views.purchased.sections.features.header", fallback: "Features") - } - public enum Products { - /// Purchases - public static let header = Strings.tr("Localizable", "views.purchased.sections.products.header", fallback: "Purchases") - } } } public enum Ui { diff --git a/Library/Sources/UILibrary/Previews/AppContext+Previews.swift b/Library/Sources/UILibrary/Previews/AppContext+Previews.swift index 2fb952908..11063a864 100644 --- a/Library/Sources/UILibrary/Previews/AppContext+Previews.swift +++ b/Library/Sources/UILibrary/Previews/AppContext+Previews.swift @@ -31,7 +31,7 @@ import PassepartoutKit extension AppContext { public static let forPreviews: AppContext = { let iapManager = IAPManager( - customUserLevel: .subscriber, + customUserLevel: .fullTV, inAppHelper: FakeAppProductHelper(), receiptReader: FakeAppReceiptReader(), betaChecker: TestFlightChecker(), diff --git a/Library/Sources/UILibrary/Resources/de.lproj/Localizable.strings b/Library/Sources/UILibrary/Resources/de.lproj/Localizable.strings index b61f82c60..f826ce69e 100644 --- a/Library/Sources/UILibrary/Resources/de.lproj/Localizable.strings +++ b/Library/Sources/UILibrary/Resources/de.lproj/Localizable.strings @@ -271,8 +271,8 @@ "views.purchased.rows.build_number" = "Build-Nummer"; "views.purchased.rows.download_date" = "Download-Datum"; "views.purchased.sections.download.header" = "Erster Download"; -"views.purchased.sections.features.header" = "Funktionen"; -"views.purchased.sections.products.header" = "Käufe"; +"global.nouns.features" = "Funktionen"; +"global.nouns.purchases" = "Käufe"; "views.purchased.title" = "Gekauft"; "views.ui.connection_status.on_demand_suffix" = " (auf Anfrage)"; "views.ui.purchase_required.purchase.help" = "Kauf erforderlich"; diff --git a/Library/Sources/UILibrary/Resources/el.lproj/Localizable.strings b/Library/Sources/UILibrary/Resources/el.lproj/Localizable.strings index 283ae3fca..c093864b7 100644 --- a/Library/Sources/UILibrary/Resources/el.lproj/Localizable.strings +++ b/Library/Sources/UILibrary/Resources/el.lproj/Localizable.strings @@ -271,8 +271,8 @@ "views.purchased.rows.build_number" = "Αριθμός κατασκευής"; "views.purchased.rows.download_date" = "Ημερομηνία λήψης"; "views.purchased.sections.download.header" = "Πρώτη λήψη"; -"views.purchased.sections.features.header" = "Λειτουργίες"; -"views.purchased.sections.products.header" = "Αγορές"; +"global.nouns.features" = "Λειτουργίες"; +"global.nouns.purchases" = "Αγορές"; "views.purchased.title" = "Αγορασμένα"; "views.ui.connection_status.on_demand_suffix" = " (κατ' απαίτηση)"; "views.ui.purchase_required.purchase.help" = "Απαιτείται αγορά"; diff --git a/Library/Sources/UILibrary/Resources/en.lproj/Localizable.strings b/Library/Sources/UILibrary/Resources/en.lproj/Localizable.strings index 3c9e9a7e7..a501b1152 100644 --- a/Library/Sources/UILibrary/Resources/en.lproj/Localizable.strings +++ b/Library/Sources/UILibrary/Resources/en.lproj/Localizable.strings @@ -115,8 +115,6 @@ "views.purchased.title" = "Purchased"; "views.purchased.sections.download.header" = "First download"; -"views.purchased.sections.products.header" = "Purchases"; -"views.purchased.sections.features.header" = "Features"; "views.purchased.rows.build_number" = "Build number"; "views.purchased.rows.download_date" = "Download date"; "views.purchased.no_purchases" = "No purchases"; @@ -271,6 +269,7 @@ "global.nouns.empty" = "Empty"; "global.nouns.enabled" = "Enabled"; "global.nouns.endpoint" = "Endpoint"; +"global.nouns.features" = "Features"; "global.nouns.filters" = "Filters"; "global.nouns.folder" = "Folder"; "global.nouns.gateway" = "Gateway"; @@ -299,6 +298,7 @@ "global.nouns.protocol" = "Protocol"; "global.nouns.provider" = "Provider"; "global.nouns.public_key" = "Public key"; +"global.nouns.purchases" = "Purchases"; "global.nouns.region" = "Region"; "global.nouns.route" = "Route"; "global.nouns.routes" = "Routes"; diff --git a/Library/Sources/UILibrary/Resources/es.lproj/Localizable.strings b/Library/Sources/UILibrary/Resources/es.lproj/Localizable.strings index f15acd4bd..7c8254984 100644 --- a/Library/Sources/UILibrary/Resources/es.lproj/Localizable.strings +++ b/Library/Sources/UILibrary/Resources/es.lproj/Localizable.strings @@ -271,8 +271,8 @@ "views.purchased.rows.build_number" = "Número de versión"; "views.purchased.rows.download_date" = "Fecha de descarga"; "views.purchased.sections.download.header" = "Primer descarga"; -"views.purchased.sections.features.header" = "Funciones"; -"views.purchased.sections.products.header" = "Compras"; +"global.nouns.features" = "Funciones"; +"global.nouns.purchases" = "Compras"; "views.purchased.title" = "Comprado"; "views.ui.connection_status.on_demand_suffix" = " (a demanda)"; "views.ui.purchase_required.purchase.help" = "Compra requerida"; diff --git a/Library/Sources/UILibrary/Resources/fr.lproj/Localizable.strings b/Library/Sources/UILibrary/Resources/fr.lproj/Localizable.strings index 66ef1c24a..4f1279205 100644 --- a/Library/Sources/UILibrary/Resources/fr.lproj/Localizable.strings +++ b/Library/Sources/UILibrary/Resources/fr.lproj/Localizable.strings @@ -271,8 +271,8 @@ "views.purchased.rows.build_number" = "Numéro de version"; "views.purchased.rows.download_date" = "Date de téléchargement"; "views.purchased.sections.download.header" = "Premier téléchargement"; -"views.purchased.sections.features.header" = "Fonctionnalités"; -"views.purchased.sections.products.header" = "Achats"; +"global.nouns.features" = "Fonctionnalités"; +"global.nouns.purchases" = "Achats"; "views.purchased.title" = "Acheté"; "views.ui.connection_status.on_demand_suffix" = " (à la demande)"; "views.ui.purchase_required.purchase.help" = "Achat requis"; diff --git a/Library/Sources/UILibrary/Resources/it.lproj/Localizable.strings b/Library/Sources/UILibrary/Resources/it.lproj/Localizable.strings index 7d047bf37..e764f3c06 100644 --- a/Library/Sources/UILibrary/Resources/it.lproj/Localizable.strings +++ b/Library/Sources/UILibrary/Resources/it.lproj/Localizable.strings @@ -271,8 +271,8 @@ "views.purchased.rows.build_number" = "Numero di build"; "views.purchased.rows.download_date" = "Data di download"; "views.purchased.sections.download.header" = "Primo download"; -"views.purchased.sections.features.header" = "Funzionalità"; -"views.purchased.sections.products.header" = "Acquisti"; +"global.nouns.features" = "Funzionalità"; +"global.nouns.purchases" = "Acquisti"; "views.purchased.title" = "Acquistato"; "views.ui.connection_status.on_demand_suffix" = " (on-demand)"; "views.ui.purchase_required.purchase.help" = "Acquisto richiesto"; diff --git a/Library/Sources/UILibrary/Resources/nl.lproj/Localizable.strings b/Library/Sources/UILibrary/Resources/nl.lproj/Localizable.strings index fd180571d..059fa6807 100644 --- a/Library/Sources/UILibrary/Resources/nl.lproj/Localizable.strings +++ b/Library/Sources/UILibrary/Resources/nl.lproj/Localizable.strings @@ -271,8 +271,8 @@ "views.purchased.rows.build_number" = "Buildnummer"; "views.purchased.rows.download_date" = "Downloaddatum"; "views.purchased.sections.download.header" = "Eerste download"; -"views.purchased.sections.features.header" = "Functies"; -"views.purchased.sections.products.header" = "Aankopen"; +"global.nouns.features" = "Functies"; +"global.nouns.purchases" = "Aankopen"; "views.purchased.title" = "Aangekocht"; "views.ui.connection_status.on_demand_suffix" = " (op aanvraag)"; "views.ui.purchase_required.purchase.help" = "Aankoop vereist"; diff --git a/Library/Sources/UILibrary/Resources/pl.lproj/Localizable.strings b/Library/Sources/UILibrary/Resources/pl.lproj/Localizable.strings index a4709b1b0..a4545c01b 100644 --- a/Library/Sources/UILibrary/Resources/pl.lproj/Localizable.strings +++ b/Library/Sources/UILibrary/Resources/pl.lproj/Localizable.strings @@ -271,8 +271,8 @@ "views.purchased.rows.build_number" = "Numer kompilacji"; "views.purchased.rows.download_date" = "Data pobrania"; "views.purchased.sections.download.header" = "Pierwsze pobranie"; -"views.purchased.sections.features.header" = "Funkcje"; -"views.purchased.sections.products.header" = "Zakupy"; +"global.nouns.features" = "Funkcje"; +"global.nouns.purchases" = "Zakupy"; "views.purchased.title" = "Zakupione"; "views.ui.connection_status.on_demand_suffix" = " (na żądanie)"; "views.ui.purchase_required.purchase.help" = "Wymagana zakup"; diff --git a/Library/Sources/UILibrary/Resources/pt.lproj/Localizable.strings b/Library/Sources/UILibrary/Resources/pt.lproj/Localizable.strings index 6856a107e..fb43c6c60 100644 --- a/Library/Sources/UILibrary/Resources/pt.lproj/Localizable.strings +++ b/Library/Sources/UILibrary/Resources/pt.lproj/Localizable.strings @@ -271,8 +271,8 @@ "views.purchased.rows.build_number" = "Número da compilação"; "views.purchased.rows.download_date" = "Data do download"; "views.purchased.sections.download.header" = "Primeiro download"; -"views.purchased.sections.features.header" = "Recursos"; -"views.purchased.sections.products.header" = "Compras"; +"global.nouns.features" = "Recursos"; +"global.nouns.purchases" = "Compras"; "views.purchased.title" = "Comprado"; "views.ui.connection_status.on_demand_suffix" = " (sob demanda)"; "views.ui.purchase_required.purchase.help" = "Compra necessária"; diff --git a/Library/Sources/UILibrary/Resources/ru.lproj/Localizable.strings b/Library/Sources/UILibrary/Resources/ru.lproj/Localizable.strings index fbbee72ab..266269516 100644 --- a/Library/Sources/UILibrary/Resources/ru.lproj/Localizable.strings +++ b/Library/Sources/UILibrary/Resources/ru.lproj/Localizable.strings @@ -271,8 +271,8 @@ "views.purchased.rows.build_number" = "Номер сборки"; "views.purchased.rows.download_date" = "Дата скачивания"; "views.purchased.sections.download.header" = "Первое скачивание"; -"views.purchased.sections.features.header" = "Функции"; -"views.purchased.sections.products.header" = "Покупки"; +"global.nouns.features" = "Функции"; +"global.nouns.purchases" = "Покупки"; "views.purchased.title" = "Куплено"; "views.ui.connection_status.on_demand_suffix" = " (по требованию)"; "views.ui.purchase_required.purchase.help" = "Требуется покупка"; diff --git a/Library/Sources/UILibrary/Resources/sv.lproj/Localizable.strings b/Library/Sources/UILibrary/Resources/sv.lproj/Localizable.strings index 81276390d..1efd31905 100644 --- a/Library/Sources/UILibrary/Resources/sv.lproj/Localizable.strings +++ b/Library/Sources/UILibrary/Resources/sv.lproj/Localizable.strings @@ -271,8 +271,8 @@ "views.purchased.rows.build_number" = "Byggnummer"; "views.purchased.rows.download_date" = "Nedladdningsdatum"; "views.purchased.sections.download.header" = "Första nedladdning"; -"views.purchased.sections.features.header" = "Funktioner"; -"views.purchased.sections.products.header" = "Köp"; +"global.nouns.features" = "Funktioner"; +"global.nouns.purchases" = "Köp"; "views.purchased.title" = "Köpt"; "views.ui.connection_status.on_demand_suffix" = " (på begäran)"; "views.ui.purchase_required.purchase.help" = "Köp krävs"; diff --git a/Library/Sources/UILibrary/Resources/uk.lproj/Localizable.strings b/Library/Sources/UILibrary/Resources/uk.lproj/Localizable.strings index 74402f95a..1bff90e6f 100644 --- a/Library/Sources/UILibrary/Resources/uk.lproj/Localizable.strings +++ b/Library/Sources/UILibrary/Resources/uk.lproj/Localizable.strings @@ -271,8 +271,8 @@ "views.purchased.rows.build_number" = "Номер збірки"; "views.purchased.rows.download_date" = "Дата завантаження"; "views.purchased.sections.download.header" = "Перший завантаження"; -"views.purchased.sections.features.header" = "Функції"; -"views.purchased.sections.products.header" = "Придбання"; +"global.nouns.features" = "Функції"; +"global.nouns.purchases" = "Придбання"; "views.purchased.title" = "Придбано"; "views.ui.connection_status.on_demand_suffix" = " (за запитом)"; "views.ui.purchase_required.purchase.help" = "Потрібна покупка"; diff --git a/Library/Sources/UILibrary/Resources/zh-Hans.lproj/Localizable.strings b/Library/Sources/UILibrary/Resources/zh-Hans.lproj/Localizable.strings index fc52f9395..7273697dc 100644 --- a/Library/Sources/UILibrary/Resources/zh-Hans.lproj/Localizable.strings +++ b/Library/Sources/UILibrary/Resources/zh-Hans.lproj/Localizable.strings @@ -271,8 +271,8 @@ "views.purchased.rows.build_number" = "构建编号"; "views.purchased.rows.download_date" = "下载日期"; "views.purchased.sections.download.header" = "首次下载"; -"views.purchased.sections.features.header" = "功能"; -"views.purchased.sections.products.header" = "购买内容"; +"global.nouns.features" = "功能"; +"global.nouns.purchases" = "购买内容"; "views.purchased.title" = "已购买"; "views.ui.connection_status.on_demand_suffix" = "(按需)"; "views.ui.purchase_required.purchase.help" = "需要购买"; diff --git a/Library/Sources/UILibrary/Views/About/PurchasedView.swift b/Library/Sources/UILibrary/Views/About/PurchasedView.swift index 475113408..33feed937 100644 --- a/Library/Sources/UILibrary/Views/About/PurchasedView.swift +++ b/Library/Sources/UILibrary/Views/About/PurchasedView.swift @@ -125,7 +125,7 @@ private extension PurchasedView { Text(Strings.Views.Purchased.noPurchases) } } - .themeSection(header: Strings.Views.Purchased.Sections.Products.header) + .themeSection(header: Strings.Global.Nouns.purchases) } var featuresSection: some View { @@ -135,7 +135,7 @@ private extension PurchasedView { .scrollableOnTV() } } - .themeSection(header: Strings.Views.Purchased.Sections.Features.header) + .themeSection(header: Strings.Global.Nouns.features) } } diff --git a/Library/Sources/UILibrary/Views/Modules/OpenVPNView+Credentials.swift b/Library/Sources/UILibrary/Views/Modules/OpenVPNView+Credentials.swift index 13962c72f..861f69dc8 100644 --- a/Library/Sources/UILibrary/Views/Modules/OpenVPNView+Credentials.swift +++ b/Library/Sources/UILibrary/Views/Modules/OpenVPNView+Credentials.swift @@ -61,9 +61,6 @@ public struct OpenVPNCredentialsView: View { @State private var providerCustomization: OpenVPN.ProviderCustomization? - @State - private var paywallReason: PaywallReason? - @FocusState private var focusedField: Field? @@ -93,7 +90,6 @@ public struct OpenVPNCredentialsView: View { .themeManualInput() .themeAnimation(on: isInteractive, category: .modules) .themeAnimation(on: builder, category: .modules) - .modifier(PaywallModifier(reason: $paywallReason)) .onLoad(perform: onLoad) .onChange(of: builder, perform: onChange) } @@ -105,7 +101,7 @@ private extension OpenVPNCredentialsView { Toggle(isOn: $isInteractive) { HStack { Text(Strings.Modules.Openvpn.Credentials.interactive) - PurchaseRequiredButton(features: requiredFeatures, paywallReason: $paywallReason) + PurchaseRequiredButton(features: requiredFeatures) } } .themeRowWithSubtitle(interactiveFooter) diff --git a/Library/Sources/UILibrary/Views/Paywall/PaywallView.swift b/Library/Sources/UILibrary/Views/Paywall/PaywallView.swift index b6b25ae61..d518283ee 100644 --- a/Library/Sources/UILibrary/Views/Paywall/PaywallView.swift +++ b/Library/Sources/UILibrary/Views/Paywall/PaywallView.swift @@ -42,16 +42,11 @@ struct PaywallView: View { let features: Set - let suggestedProduct: AppProduct? - @State private var isFetchingProducts = true @State - private var suggestedIAP: InAppProduct? - - @State - private var fullIAPs: [InAppProduct] = [] + private var iaps: [InAppProduct] = [] @State private var purchasingIdentifier: String? @@ -89,10 +84,9 @@ private extension PaywallView { var contentView: some View { Form { requiredFeaturesView - suggestedProductView - if !fullIAPs.isEmpty { - fullProductsView - allFeaturesView + productsView + if !iapManager.isFullVersionPurchaser { + fullVersionFeaturesView } restoreView } @@ -100,22 +94,20 @@ private extension PaywallView { .disabled(purchasingIdentifier != nil) } - var suggestedProductView: some View { - suggestedIAP.map { iap in - PaywallProductView( - iapManager: iapManager, - style: .oneTime, - product: iap, - purchasingIdentifier: $purchasingIdentifier, - onComplete: onComplete, - onError: onError - ) - .themeSection(header: Strings.Views.Paywall.Sections.SuggestedProduct.header) - } + var requiredFeaturesView: some View { + FeatureListView( + style: .list, + header: Strings.Views.Paywall.Sections.RequiredFeatures.header, + features: Array(features), + content: { + featureView(for: $0) + .fontWeight(theme.relevantWeight) + } + ) } - var fullProductsView: some View { - ForEach(fullIAPs, id: \.productIdentifier) { + var productsView: some View { + ForEach(iaps, id: \.productIdentifier) { PaywallProductView( iapManager: iapManager, style: .recurring, @@ -125,26 +117,14 @@ private extension PaywallView { onError: onError ) } - .themeSection(header: Strings.Views.Paywall.Sections.FullProducts.header) - } - - var requiredFeaturesView: some View { - FeatureListView( - style: .list, - header: Strings.Views.Paywall.Sections.RequiredFeatures.header, - features: Array(features), - content: { - featureView(for: $0) - .fontWeight(theme.relevantWeight) - } - ) + .themeSection(header: Strings.Global.Nouns.purchases) } - var allFeaturesView: some View { + var fullVersionFeaturesView: some View { FeatureListView( style: allFeaturesStyle, header: Strings.Views.Paywall.Sections.AllFeatures.header, - features: allFeatures, + features: fullVersionFeatures, content: featureView(for:) ) } @@ -195,20 +175,8 @@ private extension PaywallView { // MARK: - private extension PaywallView { - var allFeatures: [AppFeature] { - AppFeature.allCases - } - - var lifetimeProduct: AppProduct { - .Full.OneTime.lifetime - } - - var yearlyProduct: AppProduct { - .Full.Recurring.yearly - } - - var monthlyProduct: AppProduct { - .Full.Recurring.monthly + var fullVersionFeatures: [AppFeature] { + AppFeature.fullFeatures } func fetchAvailableProducts() async { @@ -216,41 +184,17 @@ private extension PaywallView { defer { isFetchingProducts = false } - - var list: [AppProduct] = [] - if let suggestedProduct { - list.append(suggestedProduct) - } - list.append(lifetimeProduct) - list.append(yearlyProduct) - list.append(monthlyProduct) - do { - let availableProducts = try await iapManager.purchasableProducts(for: list) + let availableProducts = iapManager.suggestedProducts(for: features) guard !availableProducts.isEmpty else { throw AppError.emptyProducts } - - var suggestedIAP: InAppProduct? - var fullIAPs: [InAppProduct] = [] - availableProducts.forEach { - if let suggestedProduct, $0.productIdentifier.hasSuffix(suggestedProduct.rawValue) { - suggestedIAP = $0 - } else { - fullIAPs.append($0) - } - } - - pp_log(.App.iap, .info, "Available products: \(availableProducts)") - pp_log(.App.iap, .info, "\tSuggested: \(suggestedIAP.debugDescription)") - pp_log(.App.iap, .info, "\tFull: \(fullIAPs)") - - guard suggestedIAP != nil || !fullIAPs.isEmpty else { + iaps = try await iapManager.purchasableProducts(for: availableProducts) + pp_log(.App.iap, .info, "Suggested products: \(availableProducts)") + pp_log(.App.iap, .info, "\tIAPs: \(iaps)") + guard !iaps.isEmpty else { throw AppError.emptyProducts } - - self.suggestedIAP = suggestedIAP - self.fullIAPs = fullIAPs } catch { onError(error, dismissing: true) } @@ -293,8 +237,7 @@ private extension PaywallView { #Preview { PaywallView( isPresented: .constant(true), - features: [.appleTV], - suggestedProduct: .Features.appleTV + features: [.appleTV] ) .withMockEnvironment() } diff --git a/Library/Sources/UILibrary/Views/UI/PaywallModifier+Reason.swift b/Library/Sources/UILibrary/Views/UI/PaywallModifier+Reason.swift index ad23b301c..d54e37ff3 100644 --- a/Library/Sources/UILibrary/Views/UI/PaywallModifier+Reason.swift +++ b/Library/Sources/UILibrary/Views/UI/PaywallModifier+Reason.swift @@ -32,17 +32,13 @@ extension PaywallModifier { public struct Reason: Hashable { public let requiredFeatures: Set - public let suggestedProduct: AppProduct? - public let needsConfirmation: Bool public init( _ requiredFeatures: Set, - suggestedProduct: AppProduct? = nil, needsConfirmation: Bool = false ) { self.requiredFeatures = requiredFeatures - self.suggestedProduct = suggestedProduct self.needsConfirmation = needsConfirmation } } diff --git a/Library/Sources/UILibrary/Views/UI/PaywallModifier.swift b/Library/Sources/UILibrary/Views/UI/PaywallModifier.swift index 415ae8f65..f38d3b039 100644 --- a/Library/Sources/UILibrary/Views/UI/PaywallModifier.swift +++ b/Library/Sources/UILibrary/Views/UI/PaywallModifier.swift @@ -189,8 +189,7 @@ private extension PaywallModifier { reason.map { PaywallView( isPresented: $isPurchasing, - features: iapManager.excludingEligible(from: $0.requiredFeatures), - suggestedProduct: $0.suggestedProduct + features: iapManager.excludingEligible(from: $0.requiredFeatures) ) .themeNavigationStack() } diff --git a/Library/Sources/UILibrary/Views/UI/PurchaseRequiredButton.swift b/Library/Sources/UILibrary/Views/UI/PurchaseRequiredButton.swift index 740868416..ce8655cfb 100644 --- a/Library/Sources/UILibrary/Views/UI/PurchaseRequiredButton.swift +++ b/Library/Sources/UILibrary/Views/UI/PurchaseRequiredButton.swift @@ -35,11 +35,6 @@ public struct PurchaseRequiredButton: View where Content: View { let features: Set? - let suggestedProduct: AppProduct? - - @Binding - var paywallReason: PaywallReason? - @ViewBuilder let content: (_ isRestricted: Bool, _ action: @escaping () -> Void) -> Content @@ -51,12 +46,7 @@ public struct PurchaseRequiredButton: View where Content: View { private extension PurchaseRequiredButton { func onTap() { - guard let features, !isEligible else { - return - } - setLater(.init(features, suggestedProduct: suggestedProduct)) { - paywallReason = $0 - } + // } var isEligible: Bool { @@ -72,13 +62,9 @@ private extension PurchaseRequiredButton { extension PurchaseRequiredButton where Content == Button { public init( _ title: String, - features: Set?, - suggestedProduct: AppProduct? = nil, - paywallReason: Binding + features: Set? ) { self.features = features - self.suggestedProduct = suggestedProduct - _paywallReason = paywallReason content = { _, action in Button(title, action: action) } @@ -86,26 +72,15 @@ extension PurchaseRequiredButton where Content == Button { } extension PurchaseRequiredButton where Content == PurchaseRequiredImageButtonContent { - public init( - for requiring: AppFeatureRequiring?, - suggestedProduct: AppProduct? = nil, - paywallReason: Binding - ) { - self.init( - features: requiring?.features, - suggestedProduct: suggestedProduct, - paywallReason: paywallReason - ) + + // FIXME: ###, only Profile/ProfileEditor + public init(for requiring: AppFeatureRequiring?) { + self.init(features: requiring?.features) } - public init( - features: Set?, - suggestedProduct: AppProduct? = nil, - paywallReason: Binding - ) { + // FIXME: ###, only Profile/ProfileEditor + public init(features: Set?) { self.features = features - self.suggestedProduct = suggestedProduct - _paywallReason = paywallReason content = { PurchaseRequiredImageButtonContent(isRestricted: $0, action: $1) } diff --git a/Library/Tests/CommonLibraryTests/Business/IAPManagerTests.swift b/Library/Tests/CommonLibraryTests/Business/IAPManagerTests.swift index ab1e8718e..e1c600d6c 100644 --- a/Library/Tests/CommonLibraryTests/Business/IAPManagerTests.swift +++ b/Library/Tests/CommonLibraryTests/Business/IAPManagerTests.swift @@ -49,7 +49,7 @@ extension IAPManagerTests { let sut = IAPManager(receiptReader: reader) let appProducts: [AppProduct] = [ - .Full.iOS, + .Full.OneTime.full, .Donations.huge ] let inAppProducts = try await sut.purchasableProducts(for: appProducts) @@ -88,12 +88,12 @@ extension IAPManagerTests { await reader.setReceipt(withBuild: olderBuildNumber, identifiers: []) let sut = IAPManager(receiptReader: reader) { build in if build <= self.defaultBuildNumber { - return [.Full.allPlatforms] + return [.Full.OneTime.full] } return [] } await sut.reloadReceipt() - XCTAssertTrue(sut.isEligible(for: AppFeature.fullV2Features)) + XCTAssertTrue(sut.isEligible(for: AppFeature.fullFeatures)) } func test_givenBuildProducts_whenNewer_thenFreeVersion() async { @@ -101,12 +101,12 @@ extension IAPManagerTests { await reader.setReceipt(withBuild: newerBuildNumber, products: []) let sut = IAPManager(receiptReader: reader) { build in if build <= self.defaultBuildNumber { - return [.Full.allPlatforms] + return [.Full.OneTime.full] } return [] } await sut.reloadReceipt() - XCTAssertFalse(sut.isEligible(for: AppFeature.fullV2Features)) + XCTAssertFalse(sut.isEligible(for: AppFeature.fullFeatures)) } } @@ -117,13 +117,13 @@ extension IAPManagerTests { let reader = FakeAppReceiptReader() let sut = IAPManager(receiptReader: reader) - XCTAssertFalse(sut.isEligible(for: AppFeature.fullV2Features)) + XCTAssertFalse(sut.isEligible(for: AppFeature.fullFeatures)) - await reader.setReceipt(withBuild: defaultBuildNumber, products: [.Full.allPlatforms]) - XCTAssertFalse(sut.isEligible(for: AppFeature.fullV2Features)) + await reader.setReceipt(withBuild: defaultBuildNumber, products: [.Full.OneTime.full]) + XCTAssertFalse(sut.isEligible(for: AppFeature.fullFeatures)) await sut.reloadReceipt() - XCTAssertTrue(sut.isEligible(for: AppFeature.fullV2Features)) + XCTAssertTrue(sut.isEligible(for: AppFeature.fullFeatures)) } func test_givenPurchasedFeatures_thenIsOnlyEligibleForFeatures() async { @@ -139,20 +139,20 @@ extension IAPManagerTests { XCTAssertFalse(sut.isEligible(for: .onDemand)) XCTAssertTrue(sut.isEligible(for: .routing)) XCTAssertFalse(sut.isEligible(for: .sharing)) - XCTAssertFalse(sut.isEligible(for: AppFeature.fullV2Features)) + XCTAssertFalse(sut.isEligible(for: AppFeature.fullFeatures)) } func test_givenPurchasedAndCancelledFeature_thenIsNotEligible() async { let reader = FakeAppReceiptReader() await reader.setReceipt( withBuild: defaultBuildNumber, - products: [.Full.allPlatforms], - cancelledProducts: [.Full.allPlatforms] + products: [.Full.OneTime.full], + cancelledProducts: [.Full.OneTime.full] ) let sut = IAPManager(receiptReader: reader) await sut.reloadReceipt() - XCTAssertFalse(sut.isEligible(for: AppFeature.fullV2Features)) + XCTAssertFalse(sut.isEligible(for: AppFeature.fullFeatures)) } func test_givenFreeVersion_thenIsNotEligibleForAnyFeature() async { @@ -161,8 +161,7 @@ extension IAPManagerTests { let sut = IAPManager(receiptReader: reader) await sut.reloadReceipt() - XCTAssertFalse(sut.userLevel.isFullVersion) - AppFeature.fullV2Features.forEach { + AppFeature.fullFeatures.forEach { XCTAssertFalse(sut.isEligible(for: $0)) } } @@ -178,7 +177,7 @@ extension IAPManagerTests { func test_givenFullV2Version_thenIsEligibleForAnyFeatureExceptExcluded() async { let reader = FakeAppReceiptReader() - await reader.setReceipt(withBuild: defaultBuildNumber, products: [.Full.allPlatforms]) + await reader.setReceipt(withBuild: defaultBuildNumber, products: [.Full.OneTime.full]) let sut = IAPManager(receiptReader: reader) await sut.reloadReceipt() @@ -187,7 +186,7 @@ extension IAPManagerTests { .interactiveLogin ] AppFeature.allCases.forEach { - if AppFeature.fullV2Features.contains($0) { + if AppFeature.fullFeatures.contains($0) { XCTAssertTrue(sut.isEligible(for: $0)) } else { XCTAssertTrue(excluded.contains($0)) @@ -211,13 +210,13 @@ extension IAPManagerTests { let sut = IAPManager(receiptReader: reader) #if os(macOS) - await reader.setReceipt(withBuild: defaultBuildNumber, products: [.Full.macOS, .Features.networkSettings]) + await reader.setReceipt(withBuild: defaultBuildNumber, products: [.Full.OneTime.macOS, .Features.networkSettings]) await sut.reloadReceipt() - XCTAssertTrue(sut.isEligible(for: AppFeature.fullV2Features)) + XCTAssertTrue(sut.isEligible(for: AppFeature.fullFeatures)) #else - await reader.setReceipt(withBuild: defaultBuildNumber, products: [.Full.iOS, .Features.networkSettings]) + await reader.setReceipt(withBuild: defaultBuildNumber, products: [.Full.OneTime.iOS, .Features.networkSettings]) await sut.reloadReceipt() - XCTAssertTrue(sut.isEligible(for: AppFeature.fullV2Features)) + XCTAssertTrue(sut.isEligible(for: AppFeature.fullFeatures)) #endif } @@ -226,20 +225,20 @@ extension IAPManagerTests { let sut = IAPManager(receiptReader: reader) #if os(macOS) - await reader.setReceipt(withBuild: defaultBuildNumber, products: [.Full.iOS, .Features.networkSettings]) + await reader.setReceipt(withBuild: defaultBuildNumber, products: [.Full.OneTime.iOS, .Features.networkSettings]) await sut.reloadReceipt() - XCTAssertFalse(sut.isEligible(for: AppFeature.fullV2Features)) + XCTAssertFalse(sut.isEligible(for: AppFeature.fullFeatures)) #else - await reader.setReceipt(withBuild: defaultBuildNumber, products: [.Full.macOS, .Features.networkSettings]) + await reader.setReceipt(withBuild: defaultBuildNumber, products: [.Full.OneTime.macOS, .Features.networkSettings]) await sut.reloadReceipt() - XCTAssertFalse(sut.isEligible(for: AppFeature.fullV2Features)) + XCTAssertFalse(sut.isEligible(for: AppFeature.fullFeatures)) #endif } func test_givenUser_thenIsNotEligibleForFeedback() async { let reader = FakeAppReceiptReader() let sut = IAPManager(receiptReader: reader) - XCTAssertFalse(sut.isEligibleForFeedback()) + XCTAssertFalse(sut.isEligibleForFeedback) } func test_givenBeta_thenIsEligibleForFeedback() async { @@ -247,15 +246,144 @@ extension IAPManagerTests { await reader.setReceipt(withBuild: .max, identifiers: []) let sut = IAPManager(customUserLevel: .beta, receiptReader: reader) await sut.reloadReceipt() - XCTAssertTrue(sut.isEligibleForFeedback()) + XCTAssertTrue(sut.isEligibleForFeedback) } func test_givenPayingUser_thenIsEligibleForFeedback() async { let reader = FakeAppReceiptReader() - await reader.setReceipt(withBuild: .max, products: [.Full.iOS]) + await reader.setReceipt(withBuild: .max, products: [.Full.OneTime.iOS]) let sut = IAPManager(receiptReader: reader) await sut.reloadReceipt() - XCTAssertTrue(sut.isEligibleForFeedback()) + XCTAssertTrue(sut.isEligibleForFeedback) + } +} + +// MARK: - Suggestions + +extension IAPManagerTests { + func test_givenFree_whenRequireNothing_thenSuggestsNothing() async { + let sut = await IAPManager(products: []) + XCTAssertTrue(sut.suggestedProducts(for: []).isEmpty) + } + + func test_givenFree_whenRequireFeature_thenSuggestsFullAndFullTV() async { + let sut = await IAPManager(products: []) + XCTAssertEqual(sut.suggestedProducts(for: [.dns]), [ + .Full.OneTime.full, + .Full.OneTime.fullTV + ]) + } + + func test_givenFree_whenRequireAppleTV_thenSuggestsAppleTVAndFullTV() async { + let sut = await IAPManager(products: []) + XCTAssertEqual(sut.suggestedProducts(for: [.appleTV]), [ + .Features.appleTV, + .Full.OneTime.fullTV + ]) + } + + func test_givenFree_whenRequireFeatureAndAppleTV_thenSuggestsFullTV() async { + let sut = await IAPManager(products: []) + XCTAssertEqual(sut.suggestedProducts(for: [.appleTV, .providers]), [ + .Full.OneTime.fullTV + ]) + } + + func test_givenCurrentPlatform_whenRequireFeature_thenSuggestsNothing() async { + let sut = await IAPManager.withFullCurrentPlatform() + XCTAssertTrue(sut.suggestedProducts(for: [.dns]).isEmpty) + } + + func test_givenCurrentPlatform_whenRequireAppleTV_thenSuggestsAppleTVAndFullTV() async { + let sut = await IAPManager.withFullCurrentPlatform() + XCTAssertEqual(sut.suggestedProducts(for: [.appleTV]), [ + .Features.appleTV, + .Full.OneTime.fullTV + ]) + } + + func test_givenCurrentPlatform_whenRequireFeatureAndAppleTV_thenSuggestsAppleTVAndFullTV() async { + let sut = await IAPManager.withFullCurrentPlatform() + XCTAssertEqual(sut.suggestedProducts(for: [.appleTV, .providers]), [ + .Features.appleTV, + .Full.OneTime.fullTV + ]) + } + + func test_givenOtherPlatform_whenRequireFeature_thenSuggestsFullAndFullTV() async { + let sut = await IAPManager.withFullOtherPlatform() + XCTAssertEqual(sut.suggestedProducts(for: [.dns]), [ + .Full.OneTime.full, + .Full.OneTime.fullTV + ]) + } + + func test_givenOtherPlatform_whenRequireAppleTV_thenSuggestsAppleTVAndFullTV() async { + let sut = await IAPManager.withFullOtherPlatform() + XCTAssertEqual(sut.suggestedProducts(for: [.appleTV]), [ + .Features.appleTV, + .Full.OneTime.fullTV + ]) + } + + func test_givenOtherPlatform_whenRequireFeatureAndAppleTV_thenSuggestsFullTV() async { + let sut = await IAPManager.withFullOtherPlatform() + XCTAssertEqual(sut.suggestedProducts(for: [.appleTV, .providers]), [ + .Full.OneTime.fullTV + ]) + } + + func test_givenFull_whenRequireFeature_thenSuggestsNothing() async { + let sut = await IAPManager(products: [.Full.OneTime.full]) + XCTAssertTrue(sut.suggestedProducts(for: [.dns]).isEmpty) + } + + func test_givenFull_whenRequireAppleTV_thenSuggestsAppleTV() async { + let sut = await IAPManager(products: [.Full.OneTime.full]) + XCTAssertEqual(sut.suggestedProducts(for: [.appleTV]), [ + .Features.appleTV + ]) + } + + func test_givenFull_whenRequireFeatureAndAppleTV_thenSuggestsAppleTV() async { + let sut = await IAPManager(products: [.Full.OneTime.full]) + XCTAssertEqual(sut.suggestedProducts(for: [.appleTV, .providers]), [ + .Features.appleTV + ]) + } + + func test_givenAppleTV_whenRequireFeature_thenSuggestsFull() async { + let sut = await IAPManager(products: [.Features.appleTV]) + XCTAssertEqual(sut.suggestedProducts(for: [.dns]), [ + .Full.OneTime.full + ]) + } + + func test_givenAppleTV_whenRequireAppleTV_thenSuggestsNothing() async { + let sut = await IAPManager(products: [.Features.appleTV]) + XCTAssertTrue(sut.suggestedProducts(for: [.appleTV]).isEmpty) + } + + func test_givenAppleTV_whenRequireFeatureAndAppleTV_thenSuggestsFull() async { + let sut = await IAPManager(products: [.Features.appleTV]) + XCTAssertEqual(sut.suggestedProducts(for: [.appleTV, .providers]), [ + .Full.OneTime.full + ]) + } + + func test_givenAll_whenRequireFeature_thenSuggestsNothing() async { + let sut = await IAPManager(products: [.Full.OneTime.fullTV]) + XCTAssertTrue(sut.suggestedProducts(for: [.dns]).isEmpty) + } + + func test_givenAll_whenRequireAppleTV_thenSuggestsNothing() async { + let sut = await IAPManager(products: [.Full.OneTime.fullTV]) + XCTAssertTrue(sut.suggestedProducts(for: [.appleTV]).isEmpty) + } + + func test_givenAll_whenRequireFeatureAndAppleTV_thenSuggestsNothing() async { + let sut = await IAPManager(products: [.Full.OneTime.fullTV]) + XCTAssertTrue(sut.suggestedProducts(for: [.appleTV, .providers]).isEmpty) } } @@ -300,25 +428,9 @@ extension IAPManagerTests { XCTAssertTrue(sut.isEligible(for: eligible)) } - func test_givenFullV2App_thenIsFullVersion() async { - let reader = FakeAppReceiptReader() - let sut = IAPManager(customUserLevel: .fullV2, receiptReader: reader) - - await sut.reloadReceipt() - XCTAssertTrue(sut.userLevel.isFullVersion) - } - - func test_givenSubscriberApp_thenIsFullVersion() async { - let reader = FakeAppReceiptReader() - let sut = IAPManager(customUserLevel: .subscriber, receiptReader: reader) - - await sut.reloadReceipt() - XCTAssertTrue(sut.userLevel.isFullVersion) - } - func test_givenFullV2App_thenIsEligibleForAnyFeatureExceptExcluded() async { let reader = FakeAppReceiptReader() - let sut = IAPManager(customUserLevel: .fullV2, receiptReader: reader) + let sut = IAPManager(customUserLevel: .full, receiptReader: reader) await sut.reloadReceipt() let excluded: Set = [ @@ -326,7 +438,7 @@ extension IAPManagerTests { .interactiveLogin ] AppFeature.allCases.forEach { - if AppFeature.fullV2Features.contains($0) { + if AppFeature.fullFeatures.contains($0) { XCTAssertTrue(sut.isEligible(for: $0)) } else { XCTAssertTrue(excluded.contains($0)) @@ -337,10 +449,10 @@ extension IAPManagerTests { func test_givenSubscriberApp_thenIsEligibleForAnyFeature() async { let reader = FakeAppReceiptReader() - let sut = IAPManager(customUserLevel: .subscriber, receiptReader: reader) + let sut = IAPManager(customUserLevel: .fullTV, receiptReader: reader) await sut.reloadReceipt() - AppFeature.fullV2Features.forEach { + AppFeature.fullFeatures.forEach { XCTAssertTrue(sut.isEligible(for: $0)) } XCTAssertTrue(sut.isEligible(for: .appleTV)) @@ -397,7 +509,7 @@ extension IAPManagerTests { await reader.addPurchase(with: .Features.allProviders, expirationDate: Date().addingTimeInterval(-10)) await reader.addPurchase(with: .Features.appleTV) await reader.addPurchase(with: .Features.networkSettings, expirationDate: Date().addingTimeInterval(10)) - await reader.addPurchase(with: .Full.iOS, cancellationDate: Date().addingTimeInterval(-60)) + await reader.addPurchase(with: .Full.OneTime.iOS, cancellationDate: Date().addingTimeInterval(-60)) let sut = IAPManager(receiptReader: reader) await sut.reloadReceipt() @@ -417,7 +529,7 @@ extension IAPManagerTests { extension IAPManagerTests { func test_givenManager_whenObserveObjects_thenReloadsReceipt() async { let reader = FakeAppReceiptReader() - await reader.setReceipt(withBuild: .max, products: [.Full.allPlatforms]) + await reader.setReceipt(withBuild: .max, products: [.Full.OneTime.full]) let sut = IAPManager(receiptReader: reader) XCTAssertEqual(sut.userLevel, .undefined) @@ -460,4 +572,35 @@ private extension IAPManager { productsAtBuild: productsAtBuild ) } + + convenience init(products: Set) async { + let reader = FakeAppReceiptReader() + await reader.setReceipt(withBuild: .max, products: products) + self.init(receiptReader: reader) + await reloadReceipt() + } + + static func withFullCurrentPlatform() async -> IAPManager { +#if os(iOS) + await IAPManager(products: [.Full.OneTime.iOS]) +#elseif os(macOS) + await IAPManager(products: [.Full.OneTime.macOS]) +#endif + } + + static func withFullOtherPlatform() async -> IAPManager { +#if os(iOS) + await IAPManager(products: [.Full.OneTime.macOS]) +#elseif os(macOS) + await IAPManager(products: [.Full.OneTime.iOS]) +#endif + } + + static var currentPlatformProduct: AppProduct { +#if os(iOS) + .Full.OneTime.iOS +#elseif os(macOS) + .Full.OneTime.macOS +#endif + } } diff --git a/Passepartout/App/Context/AppContext+Testing.swift b/Passepartout/App/Context/AppContext+Testing.swift index 0fc4c13bb..bc0cda1e1 100644 --- a/Passepartout/App/Context/AppContext+Testing.swift +++ b/Passepartout/App/Context/AppContext+Testing.swift @@ -33,7 +33,7 @@ extension AppContext { static func forUITesting(withRegistry registry: Registry) -> AppContext { let dependencies: Dependencies = .shared let iapManager = IAPManager( - customUserLevel: .subscriber, + customUserLevel: .fullTV, inAppHelper: dependencies.appProductHelper(), receiptReader: FakeAppReceiptReader(), betaChecker: TestFlightChecker(), diff --git a/Passepartout/Shared/Dependencies+IAPManager.swift b/Passepartout/Shared/Dependencies+IAPManager.swift index 64485be38..edc83d641 100644 --- a/Passepartout/Shared/Dependencies+IAPManager.swift +++ b/Passepartout/Shared/Dependencies+IAPManager.swift @@ -47,7 +47,7 @@ extension Dependencies { { #if os(iOS) if $0 <= 2016 { - return [.Full.iOS] + return [.Full.OneTime.iOS] } else if $0 <= 3000 { return [.Features.networkSettings] }