From 7656e94efcf4eedf1c16152c63f57fb52b6ad079 Mon Sep 17 00:00:00 2001 From: Christopher Brind Date: Mon, 18 Mar 2024 12:44:52 +0000 Subject: [PATCH 1/5] Use History in Suggestions on iOS (#705) Required: Task/Issue URL: https://app.asana.com/0/0/1206524433066958/f iOS PR: duckduckgo/iOS#2552 macOS PR: duckduckgo/macos-browser#2339 What kind of version bump will this require?: Major/Minor/Patch Optional: Tech Design URL: CC: Description: Updates to support suggestions. Steps to test this PR: See respective platform PR. --- Package.swift | 18 ++++++++++++++++++ .../Suggestions/APIResult.swift | 0 .../Suggestions/Bookmark.swift | 0 .../Suggestions/HistorySuggestion.swift | 0 .../Suggestions/Query.swift | 0 .../Suggestions/Score.swift | 0 .../Suggestions/Suggestion.swift | 0 .../Suggestions/SuggestionLoading.swift | 0 .../Suggestions/SuggestionProcessing.swift | 0 .../Suggestions/SuggestionResult.swift | 0 .../APIResultTests.swift | 2 +- .../BookmarkMock.swift | 2 +- .../HistoryEntryMock.swift | 2 +- .../ScoreTests.swift | 2 +- .../SuggestionLoadingTests.swift | 2 +- .../SuggestionProcessingTests.swift | 2 +- .../SuggestionResultTests.swift | 2 +- .../SuggestionTests.swift | 2 +- 18 files changed, 26 insertions(+), 8 deletions(-) rename Sources/{BrowserServicesKit => }/Suggestions/APIResult.swift (100%) rename Sources/{BrowserServicesKit => }/Suggestions/Bookmark.swift (100%) rename Sources/{BrowserServicesKit => }/Suggestions/HistorySuggestion.swift (100%) rename Sources/{BrowserServicesKit => }/Suggestions/Query.swift (100%) rename Sources/{BrowserServicesKit => }/Suggestions/Score.swift (100%) rename Sources/{BrowserServicesKit => }/Suggestions/Suggestion.swift (100%) rename Sources/{BrowserServicesKit => }/Suggestions/SuggestionLoading.swift (100%) rename Sources/{BrowserServicesKit => }/Suggestions/SuggestionProcessing.swift (100%) rename Sources/{BrowserServicesKit => }/Suggestions/SuggestionResult.swift (100%) rename Tests/{BrowserServicesKitTests/Suggestions => SuggestionsTests}/APIResultTests.swift (98%) rename Tests/{BrowserServicesKitTests/Suggestions => SuggestionsTests}/BookmarkMock.swift (95%) rename Tests/{BrowserServicesKitTests/Suggestions => SuggestionsTests}/HistoryEntryMock.swift (96%) rename Tests/{BrowserServicesKitTests/Suggestions => SuggestionsTests}/ScoreTests.swift (98%) rename Tests/{BrowserServicesKitTests/Suggestions => SuggestionsTests}/SuggestionLoadingTests.swift (99%) rename Tests/{BrowserServicesKitTests/Suggestions => SuggestionsTests}/SuggestionProcessingTests.swift (98%) rename Tests/{BrowserServicesKitTests/Suggestions => SuggestionsTests}/SuggestionResultTests.swift (96%) rename Tests/{BrowserServicesKitTests/Suggestions => SuggestionsTests}/SuggestionTests.swift (99%) diff --git a/Package.swift b/Package.swift index 3812fde62..1370567e4 100644 --- a/Package.swift +++ b/Package.swift @@ -35,6 +35,7 @@ let package = Package( .library(name: "SecureStorage", targets: ["SecureStorage"]), .library(name: "Subscription", targets: ["Subscription"]), .library(name: "History", targets: ["History"]), + .library(name: "Suggestions", targets: ["Suggestions"]), ], dependencies: [ .package(url: "https://github.com/duckduckgo/duckduckgo-autofill.git", exact: "10.1.0"), @@ -112,6 +113,16 @@ let package = Package( ], plugins: [swiftlintPlugin] ), + .target( + name: "Suggestions", + dependencies: [ + "Common" + ], + swiftSettings: [ + .define("DEBUG", .when(configuration: .debug)) + ], + plugins: [swiftlintPlugin] + ), .executableTarget( name: "BookmarksTestDBBuilder", dependencies: [ @@ -341,6 +352,13 @@ let package = Package( ], plugins: [swiftlintPlugin] ), + .testTarget( + name: "SuggestionsTests", + dependencies: [ + "Suggestions", + ], + plugins: [swiftlintPlugin] + ), .testTarget( name: "BookmarksTests", dependencies: [ diff --git a/Sources/BrowserServicesKit/Suggestions/APIResult.swift b/Sources/Suggestions/APIResult.swift similarity index 100% rename from Sources/BrowserServicesKit/Suggestions/APIResult.swift rename to Sources/Suggestions/APIResult.swift diff --git a/Sources/BrowserServicesKit/Suggestions/Bookmark.swift b/Sources/Suggestions/Bookmark.swift similarity index 100% rename from Sources/BrowserServicesKit/Suggestions/Bookmark.swift rename to Sources/Suggestions/Bookmark.swift diff --git a/Sources/BrowserServicesKit/Suggestions/HistorySuggestion.swift b/Sources/Suggestions/HistorySuggestion.swift similarity index 100% rename from Sources/BrowserServicesKit/Suggestions/HistorySuggestion.swift rename to Sources/Suggestions/HistorySuggestion.swift diff --git a/Sources/BrowserServicesKit/Suggestions/Query.swift b/Sources/Suggestions/Query.swift similarity index 100% rename from Sources/BrowserServicesKit/Suggestions/Query.swift rename to Sources/Suggestions/Query.swift diff --git a/Sources/BrowserServicesKit/Suggestions/Score.swift b/Sources/Suggestions/Score.swift similarity index 100% rename from Sources/BrowserServicesKit/Suggestions/Score.swift rename to Sources/Suggestions/Score.swift diff --git a/Sources/BrowserServicesKit/Suggestions/Suggestion.swift b/Sources/Suggestions/Suggestion.swift similarity index 100% rename from Sources/BrowserServicesKit/Suggestions/Suggestion.swift rename to Sources/Suggestions/Suggestion.swift diff --git a/Sources/BrowserServicesKit/Suggestions/SuggestionLoading.swift b/Sources/Suggestions/SuggestionLoading.swift similarity index 100% rename from Sources/BrowserServicesKit/Suggestions/SuggestionLoading.swift rename to Sources/Suggestions/SuggestionLoading.swift diff --git a/Sources/BrowserServicesKit/Suggestions/SuggestionProcessing.swift b/Sources/Suggestions/SuggestionProcessing.swift similarity index 100% rename from Sources/BrowserServicesKit/Suggestions/SuggestionProcessing.swift rename to Sources/Suggestions/SuggestionProcessing.swift diff --git a/Sources/BrowserServicesKit/Suggestions/SuggestionResult.swift b/Sources/Suggestions/SuggestionResult.swift similarity index 100% rename from Sources/BrowserServicesKit/Suggestions/SuggestionResult.swift rename to Sources/Suggestions/SuggestionResult.swift diff --git a/Tests/BrowserServicesKitTests/Suggestions/APIResultTests.swift b/Tests/SuggestionsTests/APIResultTests.swift similarity index 98% rename from Tests/BrowserServicesKitTests/Suggestions/APIResultTests.swift rename to Tests/SuggestionsTests/APIResultTests.swift index e9b8b5e13..2bd57ea53 100644 --- a/Tests/BrowserServicesKitTests/Suggestions/APIResultTests.swift +++ b/Tests/SuggestionsTests/APIResultTests.swift @@ -17,7 +17,7 @@ // import XCTest -@testable import BrowserServicesKit +@testable import Suggestions final class APIResultTests: XCTestCase { diff --git a/Tests/BrowserServicesKitTests/Suggestions/BookmarkMock.swift b/Tests/SuggestionsTests/BookmarkMock.swift similarity index 95% rename from Tests/BrowserServicesKitTests/Suggestions/BookmarkMock.swift rename to Tests/SuggestionsTests/BookmarkMock.swift index 8ff102aa3..4e45133a8 100644 --- a/Tests/BrowserServicesKitTests/Suggestions/BookmarkMock.swift +++ b/Tests/SuggestionsTests/BookmarkMock.swift @@ -17,7 +17,7 @@ // import Foundation -@testable import BrowserServicesKit +@testable import Suggestions struct BookmarkMock: Bookmark { diff --git a/Tests/BrowserServicesKitTests/Suggestions/HistoryEntryMock.swift b/Tests/SuggestionsTests/HistoryEntryMock.swift similarity index 96% rename from Tests/BrowserServicesKitTests/Suggestions/HistoryEntryMock.swift rename to Tests/SuggestionsTests/HistoryEntryMock.swift index 80b8ef405..9ee160608 100644 --- a/Tests/BrowserServicesKitTests/Suggestions/HistoryEntryMock.swift +++ b/Tests/SuggestionsTests/HistoryEntryMock.swift @@ -18,7 +18,7 @@ import Foundation -@testable import BrowserServicesKit +@testable import Suggestions struct HistoryEntryMock: HistorySuggestion { diff --git a/Tests/BrowserServicesKitTests/Suggestions/ScoreTests.swift b/Tests/SuggestionsTests/ScoreTests.swift similarity index 98% rename from Tests/BrowserServicesKitTests/Suggestions/ScoreTests.swift rename to Tests/SuggestionsTests/ScoreTests.swift index 1d3094406..744a24e35 100644 --- a/Tests/BrowserServicesKitTests/Suggestions/ScoreTests.swift +++ b/Tests/SuggestionsTests/ScoreTests.swift @@ -18,7 +18,7 @@ import XCTest -@testable import BrowserServicesKit +@testable import Suggestions final class ScoreTests: XCTestCase { diff --git a/Tests/BrowserServicesKitTests/Suggestions/SuggestionLoadingTests.swift b/Tests/SuggestionsTests/SuggestionLoadingTests.swift similarity index 99% rename from Tests/BrowserServicesKitTests/Suggestions/SuggestionLoadingTests.swift rename to Tests/SuggestionsTests/SuggestionLoadingTests.swift index eee9fef40..4a4f74b28 100644 --- a/Tests/BrowserServicesKitTests/Suggestions/SuggestionLoadingTests.swift +++ b/Tests/SuggestionsTests/SuggestionLoadingTests.swift @@ -17,7 +17,7 @@ // import XCTest -@testable import BrowserServicesKit +@testable import Suggestions final class SuggestionLoadingTests: XCTestCase { diff --git a/Tests/BrowserServicesKitTests/Suggestions/SuggestionProcessingTests.swift b/Tests/SuggestionsTests/SuggestionProcessingTests.swift similarity index 98% rename from Tests/BrowserServicesKitTests/Suggestions/SuggestionProcessingTests.swift rename to Tests/SuggestionsTests/SuggestionProcessingTests.swift index bfe7e3f32..b75f8aa65 100644 --- a/Tests/BrowserServicesKitTests/Suggestions/SuggestionProcessingTests.swift +++ b/Tests/SuggestionsTests/SuggestionProcessingTests.swift @@ -18,7 +18,7 @@ import XCTest -@testable import BrowserServicesKit +@testable import Suggestions final class SuggestionProcessingTests: XCTestCase { diff --git a/Tests/BrowserServicesKitTests/Suggestions/SuggestionResultTests.swift b/Tests/SuggestionsTests/SuggestionResultTests.swift similarity index 96% rename from Tests/BrowserServicesKitTests/Suggestions/SuggestionResultTests.swift rename to Tests/SuggestionsTests/SuggestionResultTests.swift index 2ad800591..9dabab776 100644 --- a/Tests/BrowserServicesKitTests/Suggestions/SuggestionResultTests.swift +++ b/Tests/SuggestionsTests/SuggestionResultTests.swift @@ -17,7 +17,7 @@ // import XCTest -@testable import BrowserServicesKit +@testable import Suggestions final class SuggestionResultTests: XCTestCase { diff --git a/Tests/BrowserServicesKitTests/Suggestions/SuggestionTests.swift b/Tests/SuggestionsTests/SuggestionTests.swift similarity index 99% rename from Tests/BrowserServicesKitTests/Suggestions/SuggestionTests.swift rename to Tests/SuggestionsTests/SuggestionTests.swift index 6101849e9..148304251 100644 --- a/Tests/BrowserServicesKitTests/Suggestions/SuggestionTests.swift +++ b/Tests/SuggestionsTests/SuggestionTests.swift @@ -18,7 +18,7 @@ import XCTest -@testable import BrowserServicesKit +@testable import Suggestions final class SuggestionTests: XCTestCase { From d01c760dadbc2e987e7577e2476f95983dc6d38c Mon Sep 17 00:00:00 2001 From: Anh Do <18567+quanganhdo@users.noreply.github.com> Date: Mon, 18 Mar 2024 16:51:43 -0400 Subject: [PATCH 2/5] Remove hardcoded NetP endpoint (#734) --- .../Networking/NetworkProtectionClient.swift | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/Sources/NetworkProtection/Networking/NetworkProtectionClient.swift b/Sources/NetworkProtection/Networking/NetworkProtectionClient.swift index 37ce3a174..268be4ae3 100644 --- a/Sources/NetworkProtection/Networking/NetworkProtectionClient.swift +++ b/Sources/NetworkProtection/Networking/NetworkProtectionClient.swift @@ -170,13 +170,7 @@ final class NetworkProtectionBackendClient: NetworkProtectionClient { init(environment: VPNSettings.SelectedEnvironment = .default, isSubscriptionEnabled: Bool) { self.isSubscriptionEnabled = isSubscriptionEnabled - - // todo - https://app.asana.com/0/0/1206470585910129/f - if isSubscriptionEnabled { - self.endpointURL = URL(string: "https://staging1.netp.duckduckgo.com")! - } else { - self.endpointURL = environment.endpointURL - } + self.endpointURL = environment.endpointURL } func getLocations(authToken: String) async -> Result<[NetworkProtectionLocation], NetworkProtectionClientError> { From f4894b9c00dd7514c66d6b929c12315e0cd9c151 Mon Sep 17 00:00:00 2001 From: Anh Do <18567+quanganhdo@users.noreply.github.com> Date: Tue, 19 Mar 2024 01:07:48 -0400 Subject: [PATCH 3/5] Handle subscription-related iOS use cases (#728) Task/Issue URL: https://app.asana.com/0/414235014887631/1206844393131400/f iOS PR: duckduckgo/iOS#2597 macOS PR: duckduckgo/macos-browser#2427 This PR updates the subscription endpoint and add a UserDefaults field for the thank you messaging. --- .../NetworkProtectionTokenStore.swift | 13 +++--- ...swift => UserDefaults+showMessaging.swift} | 23 ++++++++++- ...Defaults+subscriptionOverrideEnabled.swift | 40 +++++++++++++++++++ 3 files changed, 68 insertions(+), 8 deletions(-) rename Sources/NetworkProtection/Settings/Extensions/{UserDefaults+showEntitlementMessaging.swift => UserDefaults+showMessaging.swift} (82%) create mode 100644 Sources/NetworkProtection/Settings/Extensions/UserDefaults+subscriptionOverrideEnabled.swift diff --git a/Sources/NetworkProtection/KeyManagement/NetworkProtectionTokenStore.swift b/Sources/NetworkProtection/KeyManagement/NetworkProtectionTokenStore.swift index 91f5f6b05..57c8fb270 100644 --- a/Sources/NetworkProtection/KeyManagement/NetworkProtectionTokenStore.swift +++ b/Sources/NetworkProtection/KeyManagement/NetworkProtectionTokenStore.swift @@ -43,7 +43,7 @@ public final class NetworkProtectionKeychainTokenStore: NetworkProtectionTokenSt private let isSubscriptionEnabled: Bool private let accessTokenProvider: () -> String? - private static var authTokenPrefix: String { "ddg:" } + public static var authTokenPrefix: String { "ddg:" } public struct Defaults { static let tokenStoreEntryLabel = "DuckDuckGo Network Protection Auth Token" @@ -51,6 +51,8 @@ public final class NetworkProtectionKeychainTokenStore: NetworkProtectionTokenSt static let tokenStoreName = "com.duckduckgo.networkprotection.token" } + /// - isSubscriptionEnabled: Controls whether the subscription access token is used to authenticate with the NetP backend + /// - accessTokenProvider: Defines how to actually retrieve the subscription access token public init(keychainType: KeychainType, serviceName: String = Defaults.tokenStoreService, errorEvents: EventMapping?, @@ -74,13 +76,13 @@ public final class NetworkProtectionKeychainTokenStore: NetworkProtectionTokenSt } } - public func makeToken(from subscriptionAccessToken: String) -> String { + private func makeToken(from subscriptionAccessToken: String) -> String { Self.authTokenPrefix + subscriptionAccessToken } public func fetchToken() throws -> String? { - if isSubscriptionEnabled, let authToken = accessTokenProvider() { - return makeToken(from: authToken) + if isSubscriptionEnabled { + return accessTokenProvider().map { makeToken(from: $0) } } do { @@ -114,6 +116,3 @@ public final class NetworkProtectionKeychainTokenStore: NetworkProtectionTokenSt errorEvents?.fire(error.networkProtectionError) } } - -extension NetworkProtectionTokenStore { -} diff --git a/Sources/NetworkProtection/Settings/Extensions/UserDefaults+showEntitlementMessaging.swift b/Sources/NetworkProtection/Settings/Extensions/UserDefaults+showMessaging.swift similarity index 82% rename from Sources/NetworkProtection/Settings/Extensions/UserDefaults+showEntitlementMessaging.swift rename to Sources/NetworkProtection/Settings/Extensions/UserDefaults+showMessaging.swift index cea0a17d9..d905ccde2 100644 --- a/Sources/NetworkProtection/Settings/Extensions/UserDefaults+showEntitlementMessaging.swift +++ b/Sources/NetworkProtection/Settings/Extensions/UserDefaults+showMessaging.swift @@ -1,5 +1,5 @@ // -// UserDefaults+showEntitlementMessaging.swift +// UserDefaults+showMessaging.swift // // Copyright © 2024 DuckDuckGo. All rights reserved. // @@ -85,3 +85,24 @@ extension UserDefaults { public extension Notification.Name { static let vpnEntitlementMessagingDidChange = Notification.Name("com.duckduckgo.network-protection.entitlement-messaging-changed") } + +extension UserDefaults { + private var vpnEarlyAccessOverAlertAlreadyShownKey: String { + "vpnEarlyAccessOverAlertAlreadyShown" + } + + @objc + public dynamic var vpnEarlyAccessOverAlertAlreadyShown: Bool { + get { + value(forKey: vpnEarlyAccessOverAlertAlreadyShownKey) as? Bool ?? false + } + + set { + set(newValue, forKey: vpnEarlyAccessOverAlertAlreadyShownKey) + } + } + + public func resetThankYouMessaging() { + removeObject(forKey: vpnEarlyAccessOverAlertAlreadyShownKey) + } +} diff --git a/Sources/NetworkProtection/Settings/Extensions/UserDefaults+subscriptionOverrideEnabled.swift b/Sources/NetworkProtection/Settings/Extensions/UserDefaults+subscriptionOverrideEnabled.swift new file mode 100644 index 000000000..0e2d86ffc --- /dev/null +++ b/Sources/NetworkProtection/Settings/Extensions/UserDefaults+subscriptionOverrideEnabled.swift @@ -0,0 +1,40 @@ +// +// UserDefaults+subscriptionOverrideEnabled.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import Foundation + +extension UserDefaults { + private var subscriptionOverrideEnabledKey: String { + "subscriptionOverrideEnabled" + } + + public var subscriptionOverrideEnabled: Bool? { + get { + value(forKey: subscriptionOverrideEnabledKey) as? Bool + } + + set { + set(newValue, forKey: subscriptionOverrideEnabledKey) + } + } + + public func resetsubscriptionOverrideEnabled() { + removeObject(forKey: subscriptionOverrideEnabledKey) + } +} From 6d5ffe5479f609f0a32b69e27f79dcfd423911c2 Mon Sep 17 00:00:00 2001 From: Graeme Arthur Date: Tue, 19 Mar 2024 18:25:36 +0100 Subject: [PATCH 4/5] macOS: Display error messaging for cancelled subscriptions (#714) * Add expire entitlement notification case * Make entitlements monitor public * Add show privacy pro app launcher case * Use settings env for netp client * Make staging config use staging1 --- .../Diagnostics/NetworkProtectionEntitlementMonitor.swift | 2 +- .../NetworkProtection/Networking/NetworkProtectionClient.swift | 2 +- .../Notifications/NetworkProtectionNotification.swift | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Sources/NetworkProtection/Diagnostics/NetworkProtectionEntitlementMonitor.swift b/Sources/NetworkProtection/Diagnostics/NetworkProtectionEntitlementMonitor.swift index 1f2b43f97..7d9bd6967 100644 --- a/Sources/NetworkProtection/Diagnostics/NetworkProtectionEntitlementMonitor.swift +++ b/Sources/NetworkProtection/Diagnostics/NetworkProtectionEntitlementMonitor.swift @@ -40,7 +40,7 @@ public actor NetworkProtectionEntitlementMonitor { // MARK: - Init & deinit - init() { + public init() { os_log("[+] %{public}@", log: .networkProtectionMemoryLog, type: .debug, String(describing: self)) } diff --git a/Sources/NetworkProtection/Networking/NetworkProtectionClient.swift b/Sources/NetworkProtection/Networking/NetworkProtectionClient.swift index 268be4ae3..bb1d33eb8 100644 --- a/Sources/NetworkProtection/Networking/NetworkProtectionClient.swift +++ b/Sources/NetworkProtection/Networking/NetworkProtectionClient.swift @@ -119,7 +119,7 @@ final class NetworkProtectionBackendClient: NetworkProtectionClient { enum Constants { static let productionEndpoint = URL(string: "https://controller.netp.duckduckgo.com")! - static let stagingEndpoint = URL(string: "https://staging.netp.duckduckgo.com")! + static let stagingEndpoint = URL(string: "https://staging1.netp.duckduckgo.com")! } private enum DecoderError: Error { diff --git a/Sources/NetworkProtection/Notifications/NetworkProtectionNotification.swift b/Sources/NetworkProtection/Notifications/NetworkProtectionNotification.swift index 359c318d5..829296ff5 100644 --- a/Sources/NetworkProtection/Notifications/NetworkProtectionNotification.swift +++ b/Sources/NetworkProtection/Notifications/NetworkProtectionNotification.swift @@ -107,6 +107,7 @@ public enum NetworkProtectionNotification: String { case showConnectedNotification case showIssuesNotResolvedNotification case showVPNSupersededNotification + case showExpiredEntitlementNotification case showTestNotification // Server Selection From 0c73586c2628381b8a63be65fd2bc1824e58d7f9 Mon Sep 17 00:00:00 2001 From: Daniel Bernal Date: Tue, 19 Mar 2024 18:52:00 +0100 Subject: [PATCH 5/5] Adds Privacy Pro Feature Flags (#717) * Privacy Pro Flags * Fix lint issues * Add isLaunchedOverride and isLaunchedOverrideStripe * Add SubscriptionFeatureAvailability * Add isLaunchedOverride flag * Fix duplicate * Make init public * Protocol made public * Override disallowed purchase for internal users * Empty options * Return empty options * Empty options * Add SubscriptionFeatureAvailabilityTests * Fix wrong flags checked for override * Swiftlint fixes --------- Co-authored-by: Michal Smaga --- Package.swift | 8 + .../Features/PrivacyFeature.swift | 12 + Sources/Subscription/Flows/PurchaseFlow.swift | 4 + .../SubscriptionFeatureAvailability.swift | 76 +++++ ...SubscriptionFeatureAvailabilityTests.swift | 290 ++++++++++++++++++ 5 files changed, 390 insertions(+) create mode 100644 Sources/Subscription/SubscriptionFeatureAvailability.swift create mode 100644 Tests/SubscriptionTests/SubscriptionFeatureAvailabilityTests.swift diff --git a/Package.swift b/Package.swift index 1370567e4..8de20a026 100644 --- a/Package.swift +++ b/Package.swift @@ -336,6 +336,7 @@ let package = Package( .target( name: "Subscription", dependencies: [ + "BrowserServicesKit", "Common", ], swiftSettings: [ @@ -508,6 +509,13 @@ let package = Package( ], plugins: [swiftlintPlugin] ), + .testTarget( + name: "SubscriptionTests", + dependencies: [ + "Subscription", + ], + plugins: [swiftlintPlugin] + ), ], cxxLanguageStandard: .cxx11 ) diff --git a/Sources/BrowserServicesKit/PrivacyConfig/Features/PrivacyFeature.swift b/Sources/BrowserServicesKit/PrivacyConfig/Features/PrivacyFeature.swift index 15a5e4332..51c796956 100644 --- a/Sources/BrowserServicesKit/PrivacyConfig/Features/PrivacyFeature.swift +++ b/Sources/BrowserServicesKit/PrivacyConfig/Features/PrivacyFeature.swift @@ -44,6 +44,7 @@ public enum PrivacyFeature: String { case sync case privacyDashboard case history + case privacyPro } /// An abstraction to be implemented by any "subfeature" of a given `PrivacyConfiguration` feature. @@ -112,3 +113,14 @@ public enum AutoconsentSubfeature: String, PrivacySubfeature { case onByDefault } + +public enum PrivacyProSubfeature: String, Equatable, PrivacySubfeature { + public var parent: PrivacyFeature { .privacyPro } + + case isLaunched + case isLaunchedStripe + case allowPurchase + case allowPurchaseStripe + case isLaunchedOverride + case isLaunchedOverrideStripe +} diff --git a/Sources/Subscription/Flows/PurchaseFlow.swift b/Sources/Subscription/Flows/PurchaseFlow.swift index f32765ff9..58c244b69 100644 --- a/Sources/Subscription/Flows/PurchaseFlow.swift +++ b/Sources/Subscription/Flows/PurchaseFlow.swift @@ -22,6 +22,10 @@ public struct SubscriptionOptions: Encodable { let platform: String let options: [SubscriptionOption] let features: [SubscriptionFeature] + public static var empty: SubscriptionOptions { + let features = SubscriptionFeatureName.allCases.map { SubscriptionFeature(name: $0.rawValue) } + return SubscriptionOptions(platform: "macos", options: [], features: features) + } } public struct SubscriptionOption: Encodable { diff --git a/Sources/Subscription/SubscriptionFeatureAvailability.swift b/Sources/Subscription/SubscriptionFeatureAvailability.swift new file mode 100644 index 000000000..fa3213e63 --- /dev/null +++ b/Sources/Subscription/SubscriptionFeatureAvailability.swift @@ -0,0 +1,76 @@ +// +// SubscriptionFeatureAvailability.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import BrowserServicesKit + +public protocol SubscriptionFeatureAvailability { + var isFeatureAvailable: Bool { get } + var isSubscriptionPurchaseAllowed: Bool { get } +} + +public final class DefaultSubscriptionFeatureAvailability: SubscriptionFeatureAvailability { + + private let privacyConfigurationManager: PrivacyConfigurationManaging + private let purchasePlatform: SubscriptionPurchaseEnvironment.Environment + + public init(privacyConfigurationManager: PrivacyConfigurationManaging, purchasePlatform: SubscriptionPurchaseEnvironment.Environment) { + self.privacyConfigurationManager = privacyConfigurationManager + self.purchasePlatform = purchasePlatform + } + + public var isFeatureAvailable: Bool { + isInternalUser || isSubscriptionLaunched || isSubscriptionLaunchedOverride + } + + public var isSubscriptionPurchaseAllowed: Bool { + let isPurchaseAllowed: Bool + + switch purchasePlatform { + case .appStore: + isPurchaseAllowed = privacyConfigurationManager.privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.allowPurchase) + case .stripe: + isPurchaseAllowed = privacyConfigurationManager.privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.allowPurchaseStripe) + } + + return isPurchaseAllowed || isInternalUser + } + +// MARK: - Conditions + + private var isInternalUser: Bool { + privacyConfigurationManager.internalUserDecider.isInternalUser + } + + private var isSubscriptionLaunched: Bool { + switch purchasePlatform { + case .appStore: + privacyConfigurationManager.privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.isLaunched) + case .stripe: + privacyConfigurationManager.privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.isLaunchedStripe) + } + } + + private var isSubscriptionLaunchedOverride: Bool { + switch purchasePlatform { + case .appStore: + privacyConfigurationManager.privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.isLaunchedOverride) + case .stripe: + privacyConfigurationManager.privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.isLaunchedOverrideStripe) + } + } +} diff --git a/Tests/SubscriptionTests/SubscriptionFeatureAvailabilityTests.swift b/Tests/SubscriptionTests/SubscriptionFeatureAvailabilityTests.swift new file mode 100644 index 000000000..ffac7a8a5 --- /dev/null +++ b/Tests/SubscriptionTests/SubscriptionFeatureAvailabilityTests.swift @@ -0,0 +1,290 @@ +// +// SubscriptionFeatureAvailabilityTests.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +import BrowserServicesKit +import Common +import Combine +@testable import Subscription + +final class SubscriptionFeatureAvailabilityTests: XCTestCase { + + var internalUserDeciderStore: MockInternalUserStoring! + var privacyConfig: MockPrivacyConfiguration! + var privacyConfigurationManager: MockPrivacyConfigurationManager! + + override func setUp() { + super.setUp() + internalUserDeciderStore = MockInternalUserStoring() + privacyConfig = MockPrivacyConfiguration() + + privacyConfigurationManager = MockPrivacyConfigurationManager(privacyConfig: privacyConfig, + internalUserDecider: DefaultInternalUserDecider(store: internalUserDeciderStore)) + } + + override func tearDown() { + internalUserDeciderStore = nil + privacyConfig = nil + + privacyConfigurationManager = nil + super.tearDown() + } + + // MARK: - Tests for App Store + + func testSubscriptionFeatureNotAvailableWhenAllFlagsDisabledAndNotInternalUser() { + let purchasePlatform = SubscriptionPurchaseEnvironment.Environment.appStore + + internalUserDeciderStore.isInternalUser = false + XCTAssertFalse(internalUserDeciderStore.isInternalUser) + + XCTAssertFalse(privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.isLaunched)) + XCTAssertFalse(privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.isLaunchedOverride)) + XCTAssertFalse(privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.allowPurchase)) + + let subscriptionFeatureAvailability = DefaultSubscriptionFeatureAvailability(privacyConfigurationManager: privacyConfigurationManager, + purchasePlatform: purchasePlatform) + XCTAssertFalse(subscriptionFeatureAvailability.isFeatureAvailable) + XCTAssertFalse(subscriptionFeatureAvailability.isSubscriptionPurchaseAllowed) + } + + func testSubscriptionFeatureAvailableWhenIsLaunchedFlagEnabled() { + let purchasePlatform = SubscriptionPurchaseEnvironment.Environment.appStore + + internalUserDeciderStore.isInternalUser = false + XCTAssertFalse(internalUserDeciderStore.isInternalUser) + + privacyConfig.isSubfeatureEnabledCheck = makeSubfeatureEnabledCheck(for: [.isLaunched, .allowPurchase]) + + XCTAssertTrue(privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.isLaunched)) + XCTAssertFalse(privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.isLaunchedOverride)) + XCTAssertTrue(privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.allowPurchase)) + + let subscriptionFeatureAvailability = DefaultSubscriptionFeatureAvailability(privacyConfigurationManager: privacyConfigurationManager, + purchasePlatform: purchasePlatform) + XCTAssertTrue(subscriptionFeatureAvailability.isFeatureAvailable) + XCTAssertTrue(subscriptionFeatureAvailability.isSubscriptionPurchaseAllowed) + } + + func testSubscriptionFeatureAvailableWhenIsLaunchedOverrideFlagEnabled() { + let purchasePlatform = SubscriptionPurchaseEnvironment.Environment.appStore + + internalUserDeciderStore.isInternalUser = false + XCTAssertFalse(internalUserDeciderStore.isInternalUser) + + privacyConfig.isSubfeatureEnabledCheck = makeSubfeatureEnabledCheck(for: [.isLaunchedOverride, .allowPurchase]) + + XCTAssertFalse(privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.isLaunched)) + XCTAssertTrue(privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.isLaunchedOverride)) + XCTAssertTrue(privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.allowPurchase)) + + let subscriptionFeatureAvailability = DefaultSubscriptionFeatureAvailability(privacyConfigurationManager: privacyConfigurationManager, + purchasePlatform: purchasePlatform) + XCTAssertTrue(subscriptionFeatureAvailability.isFeatureAvailable) + XCTAssertTrue(subscriptionFeatureAvailability.isSubscriptionPurchaseAllowed) + } + + func testSubscriptionFeatureAvailableAndPurchaseNotAllowed() { + let purchasePlatform = SubscriptionPurchaseEnvironment.Environment.appStore + + internalUserDeciderStore.isInternalUser = false + XCTAssertFalse(internalUserDeciderStore.isInternalUser) + + privacyConfig.isSubfeatureEnabledCheck = makeSubfeatureEnabledCheck(for: [.isLaunched]) + + XCTAssertTrue(privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.isLaunched)) + XCTAssertFalse(privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.isLaunchedOverride)) + XCTAssertFalse(privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.allowPurchase)) + + let subscriptionFeatureAvailability = DefaultSubscriptionFeatureAvailability(privacyConfigurationManager: privacyConfigurationManager, + purchasePlatform: purchasePlatform) + XCTAssertTrue(subscriptionFeatureAvailability.isFeatureAvailable) + XCTAssertFalse(subscriptionFeatureAvailability.isSubscriptionPurchaseAllowed) + } + + func testSubscriptionFeatureAvailableWhenAllFlagsDisabledAndInternalUser() { + let purchasePlatform = SubscriptionPurchaseEnvironment.Environment.appStore + + internalUserDeciderStore.isInternalUser = true + XCTAssertTrue(internalUserDeciderStore.isInternalUser) + + XCTAssertFalse(privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.isLaunched)) + XCTAssertFalse(privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.isLaunchedOverride)) + XCTAssertFalse(privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.allowPurchase)) + + let subscriptionFeatureAvailability = DefaultSubscriptionFeatureAvailability(privacyConfigurationManager: privacyConfigurationManager, + purchasePlatform: purchasePlatform) + XCTAssertTrue(subscriptionFeatureAvailability.isFeatureAvailable) + XCTAssertTrue(subscriptionFeatureAvailability.isSubscriptionPurchaseAllowed) + } + + // MARK: - Tests for Stripe + + func testStripeSubscriptionFeatureNotAvailableWhenAllFlagsDisabledAndNotInternalUser() { + let purchasePlatform = SubscriptionPurchaseEnvironment.Environment.stripe + + internalUserDeciderStore.isInternalUser = false + XCTAssertFalse(internalUserDeciderStore.isInternalUser) + + XCTAssertFalse(privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.isLaunchedStripe)) + XCTAssertFalse(privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.isLaunchedOverrideStripe)) + XCTAssertFalse(privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.allowPurchaseStripe)) + + let subscriptionFeatureAvailability = DefaultSubscriptionFeatureAvailability(privacyConfigurationManager: privacyConfigurationManager, + purchasePlatform: purchasePlatform) + XCTAssertFalse(subscriptionFeatureAvailability.isFeatureAvailable) + XCTAssertFalse(subscriptionFeatureAvailability.isSubscriptionPurchaseAllowed) + } + + func testStripeSubscriptionFeatureAvailableWhenIsLaunchedFlagEnabled() { + let purchasePlatform = SubscriptionPurchaseEnvironment.Environment.stripe + + internalUserDeciderStore.isInternalUser = false + XCTAssertFalse(internalUserDeciderStore.isInternalUser) + + privacyConfig.isSubfeatureEnabledCheck = makeSubfeatureEnabledCheck(for: [.isLaunchedStripe, .allowPurchaseStripe]) + + XCTAssertTrue(privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.isLaunchedStripe)) + XCTAssertFalse(privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.isLaunchedOverrideStripe)) + XCTAssertTrue(privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.allowPurchaseStripe)) + + let subscriptionFeatureAvailability = DefaultSubscriptionFeatureAvailability(privacyConfigurationManager: privacyConfigurationManager, + purchasePlatform: purchasePlatform) + XCTAssertTrue(subscriptionFeatureAvailability.isFeatureAvailable) + XCTAssertTrue(subscriptionFeatureAvailability.isSubscriptionPurchaseAllowed) + } + + func testStripeSubscriptionFeatureAvailableWhenIsLaunchedOverrideFlagEnabled() { + let purchasePlatform = SubscriptionPurchaseEnvironment.Environment.stripe + + internalUserDeciderStore.isInternalUser = false + XCTAssertFalse(internalUserDeciderStore.isInternalUser) + + privacyConfig.isSubfeatureEnabledCheck = makeSubfeatureEnabledCheck(for: [.isLaunchedOverrideStripe, .allowPurchaseStripe]) + + XCTAssertFalse(privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.isLaunchedStripe)) + XCTAssertTrue(privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.isLaunchedOverrideStripe)) + XCTAssertTrue(privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.allowPurchaseStripe)) + + let subscriptionFeatureAvailability = DefaultSubscriptionFeatureAvailability(privacyConfigurationManager: privacyConfigurationManager, + purchasePlatform: purchasePlatform) + XCTAssertTrue(subscriptionFeatureAvailability.isFeatureAvailable) + XCTAssertTrue(subscriptionFeatureAvailability.isSubscriptionPurchaseAllowed) + } + + func testStripeSubscriptionFeatureAvailableAndPurchaseNotAllowed() { + let purchasePlatform = SubscriptionPurchaseEnvironment.Environment.stripe + + internalUserDeciderStore.isInternalUser = false + XCTAssertFalse(internalUserDeciderStore.isInternalUser) + + privacyConfig.isSubfeatureEnabledCheck = makeSubfeatureEnabledCheck(for: [.isLaunchedStripe]) + + XCTAssertTrue(privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.isLaunchedStripe)) + XCTAssertFalse(privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.isLaunchedOverrideStripe)) + XCTAssertFalse(privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.allowPurchaseStripe)) + + let subscriptionFeatureAvailability = DefaultSubscriptionFeatureAvailability(privacyConfigurationManager: privacyConfigurationManager, + purchasePlatform: purchasePlatform) + XCTAssertTrue(subscriptionFeatureAvailability.isFeatureAvailable) + XCTAssertFalse(subscriptionFeatureAvailability.isSubscriptionPurchaseAllowed) + } + + func testStripeSubscriptionFeatureAvailableWhenAllFlagsDisabledAndInternalUser() { + let purchasePlatform = SubscriptionPurchaseEnvironment.Environment.stripe + + internalUserDeciderStore.isInternalUser = true + XCTAssertTrue(internalUserDeciderStore.isInternalUser) + + XCTAssertFalse(privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.isLaunchedStripe)) + XCTAssertFalse(privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.isLaunchedOverrideStripe)) + XCTAssertFalse(privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.allowPurchaseStripe)) + + let subscriptionFeatureAvailability = DefaultSubscriptionFeatureAvailability(privacyConfigurationManager: privacyConfigurationManager, + purchasePlatform: purchasePlatform) + XCTAssertTrue(subscriptionFeatureAvailability.isFeatureAvailable) + XCTAssertTrue(subscriptionFeatureAvailability.isSubscriptionPurchaseAllowed) + } + + // MARK: - Helper + + private func makeSubfeatureEnabledCheck(for enabledSubfeatures: [PrivacyProSubfeature]) -> (any PrivacySubfeature) -> Bool { + return { + guard let subfeature = $0 as? PrivacyProSubfeature else { return false } + return enabledSubfeatures.contains(subfeature) + } + } +} + +class MockInternalUserStoring: InternalUserStoring { + var isInternalUser: Bool = false +} + +class MockPrivacyConfiguration: PrivacyConfiguration { + + func isEnabled(featureKey: PrivacyFeature, versionProvider: AppVersionProvider) -> Bool { true } + + func stateFor(featureKey: BrowserServicesKit.PrivacyFeature, versionProvider: BrowserServicesKit.AppVersionProvider) -> BrowserServicesKit.PrivacyConfigurationFeatureState { + return .enabled + } + + var isSubfeatureEnabledCheck: ((any PrivacySubfeature) -> Bool)? + + func isSubfeatureEnabled(_ subfeature: any PrivacySubfeature, versionProvider: AppVersionProvider, randomizer: (Range) -> Double) -> Bool { + isSubfeatureEnabledCheck?(subfeature) ?? false + } + + func stateFor(_ subfeature: any PrivacySubfeature, versionProvider: AppVersionProvider, randomizer: (Range) -> Double) -> PrivacyConfigurationFeatureState { + if isSubfeatureEnabledCheck?(subfeature) == true { + return .enabled + } + return .disabled(.disabledInConfig) + } + + var identifier: String = "abcd" + var userUnprotectedDomains: [String] = [] + var tempUnprotectedDomains: [String] = [] + var trackerAllowlist: PrivacyConfigurationData.TrackerAllowlist = .init(json: ["state": "disabled"])! + func exceptionsList(forFeature featureKey: PrivacyFeature) -> [String] { [] } + func isFeature(_ feature: PrivacyFeature, enabledForDomain: String?) -> Bool { true } + func isProtected(domain: String?) -> Bool { false } + func isUserUnprotected(domain: String?) -> Bool { false } + func isTempUnprotected(domain: String?) -> Bool { false } + func isInExceptionList(domain: String?, forFeature featureKey: PrivacyFeature) -> Bool { false } + func settings(for feature: PrivacyFeature) -> PrivacyConfigurationData.PrivacyFeature.FeatureSettings { .init() } + func userEnabledProtection(forDomain: String) {} + func userDisabledProtection(forDomain: String) {} +} + +class MockPrivacyConfigurationManager: PrivacyConfigurationManaging { + var currentConfig: Data = .init() + var updatesSubject = PassthroughSubject() + let updatesPublisher: AnyPublisher + var privacyConfig: PrivacyConfiguration + let internalUserDecider: InternalUserDecider + var toggleProtectionsCounter = ToggleProtectionsCounter(eventReporting: EventMapping { _, _, _, _ in }) + func reload(etag: String?, data: Data?) -> PrivacyConfigurationManager.ReloadResult { + .downloaded + } + + init(privacyConfig: PrivacyConfiguration, internalUserDecider: InternalUserDecider) { + self.updatesPublisher = updatesSubject.eraseToAnyPublisher() + self.privacyConfig = privacyConfig + self.internalUserDecider = internalUserDecider + } +}