From b01a7ba359b650f0c5c3ab00a756e298b1ae650c Mon Sep 17 00:00:00 2001 From: Federico Cappelli Date: Thu, 23 May 2024 00:20:23 +0200 Subject: [PATCH 1/7] Subscription refactoring (#815) Task/Issue URL: https://app.asana.com/0/72649045549333/1206805455884775/f iOS PR: https://github.com/duckduckgo/iOS/pull/2842 macOS PR: https://github.com/duckduckgo/macos-browser/pull/2764 Tech Design URL: https://app.asana.com/0/481882893211075/1207147511614062/f Subscription refactoring for allowing unit testing. - DI - Removal of all singletons - Removal of all static functions use --- Package.swift | 14 ++ .../SubscriptionFeatureAvailability.swift | 6 +- .../NetworkProtectionTokenStore.swift | 5 +- ...workProtectionLocationListRepository.swift | 1 + .../AccountKeychainStorage.swift | 41 +++--- ...ountStorage.swift => AccountStoring.swift} | 4 +- .../SubscriptionTokenKeychainStorage.swift | 6 +- ...e.swift => SubscriptionTokenStoring.swift} | 4 +- .../AppStoreAccountManagementFlow.swift | 27 ++-- .../Flows/AppStore/AppStorePurchaseFlow.swift | 67 +++------ .../Flows/AppStore/AppStoreRestoreFlow.swift | 22 ++- .../Flows/Models/PurchaseUpdate.swift | 29 ++++ .../Models/SubscriptionEnvironmentNames.swift | 35 +++++ .../SubscriptionOptions.swift} | 34 +---- .../Flows/Stripe/StripePurchaseFlow.swift | 36 +++-- .../NSNotificationName+Subscription.swift | 5 + .../Subscription/Services/APIService.swift | 18 +-- .../Subscription/Services/AuthService.swift | 24 +-- .../Services/Model/Subscription.swift | 4 +- .../Services/SubscriptionService.swift | 39 ++--- .../{ => SubManagers}/AccountManager.swift | 90 +++++------- .../SubManagers/AccountManaging.swift | 58 ++++++++ .../StorePurchaseManager.swift} | 114 ++++++++------- .../SubManagers/StorePurchaseManaging.swift | 58 ++++++++ .../SubscriptionEnvironment.swift | 45 ++++++ .../Subscription/SubscriptionManager.swift | 138 ++++++++++++++++++ .../SubscriptionPurchaseEnvironment.swift | 81 ---------- Sources/Subscription/SubscriptionURL.swift | 92 ++++++++++++ Sources/Subscription/URL+Subscription.swift | 101 ------------- .../AccountManagerMock.swift | 101 +++++++++++++ .../SubscriptionManagerMock.swift | 63 ++++++++ ...SubscriptionFeatureAvailabilityTests.swift | 40 ++--- .../SubscriptionTests/SubscriptionTests.swift | 25 ++++ 33 files changed, 924 insertions(+), 503 deletions(-) rename Sources/Subscription/AccountStorage/{AccountStorage.swift => AccountStoring.swift} (93%) rename Sources/Subscription/AccountStorage/{SubscriptionTokenStorage.swift => SubscriptionTokenStoring.swift} (89%) create mode 100644 Sources/Subscription/Flows/Models/PurchaseUpdate.swift create mode 100644 Sources/Subscription/Flows/Models/SubscriptionEnvironmentNames.swift rename Sources/Subscription/Flows/{PurchaseFlow.swift => Models/SubscriptionOptions.swift} (63%) rename Sources/Subscription/{ => SubManagers}/AccountManager.swift (77%) create mode 100644 Sources/Subscription/SubManagers/AccountManaging.swift rename Sources/Subscription/{PurchaseManager.swift => SubManagers/StorePurchaseManager.swift} (57%) create mode 100644 Sources/Subscription/SubManagers/StorePurchaseManaging.swift create mode 100644 Sources/Subscription/SubscriptionEnvironment.swift create mode 100644 Sources/Subscription/SubscriptionManager.swift delete mode 100644 Sources/Subscription/SubscriptionPurchaseEnvironment.swift create mode 100644 Sources/Subscription/SubscriptionURL.swift delete mode 100644 Sources/Subscription/URL+Subscription.swift create mode 100644 Sources/SubscriptionTestingUtilities/AccountManagerMock.swift create mode 100644 Sources/SubscriptionTestingUtilities/SubscriptionManagerMock.swift create mode 100644 Tests/SubscriptionTests/SubscriptionTests.swift diff --git a/Package.swift b/Package.swift index dc2d51bb6..6a67da598 100644 --- a/Package.swift +++ b/Package.swift @@ -32,6 +32,7 @@ let package = Package( .library(name: "NetworkProtectionTestUtils", targets: ["NetworkProtectionTestUtils"]), .library(name: "SecureStorage", targets: ["SecureStorage"]), .library(name: "Subscription", targets: ["Subscription"]), + .library(name: "SubscriptionTestingUtilities", targets: ["SubscriptionTestingUtilities"]), .library(name: "History", targets: ["History"]), .library(name: "Suggestions", targets: ["Suggestions"]), .library(name: "PixelKit", targets: ["PixelKit"]), @@ -331,6 +332,12 @@ let package = Package( .define("DEBUG", .when(configuration: .debug)) ] ), + .target( + name: "SubscriptionTestingUtilities", + dependencies: [ + "Subscription" + ] + ), .target( name: "PixelKit", swiftSettings: [ @@ -505,6 +512,13 @@ let package = Package( "TestUtils", ] ), + .testTarget( + name: "SubscriptionTests", + dependencies: [ + "Subscription", + "SubscriptionTestingUtilities", + ] + ), .testTarget( name: "PixelKitTests", dependencies: [ diff --git a/Sources/BrowserServicesKit/SubscriptionFeatureAvailability.swift b/Sources/BrowserServicesKit/SubscriptionFeatureAvailability.swift index cff951495..1fb03ce44 100644 --- a/Sources/BrowserServicesKit/SubscriptionFeatureAvailability.swift +++ b/Sources/BrowserServicesKit/SubscriptionFeatureAvailability.swift @@ -16,6 +16,7 @@ // limitations under the License. // +import Foundation import Subscription public protocol SubscriptionFeatureAvailability { @@ -26,9 +27,10 @@ public protocol SubscriptionFeatureAvailability { public final class DefaultSubscriptionFeatureAvailability: SubscriptionFeatureAvailability { private let privacyConfigurationManager: PrivacyConfigurationManaging - private let purchasePlatform: SubscriptionPurchaseEnvironment.Environment + private let purchasePlatform: SubscriptionEnvironment.PurchasePlatform - public init(privacyConfigurationManager: PrivacyConfigurationManaging, purchasePlatform: SubscriptionPurchaseEnvironment.Environment) { + public init(privacyConfigurationManager: PrivacyConfigurationManaging, + purchasePlatform: SubscriptionEnvironment.PurchasePlatform) { self.privacyConfigurationManager = privacyConfigurationManager self.purchasePlatform = purchasePlatform } diff --git a/Sources/NetworkProtection/KeyManagement/NetworkProtectionTokenStore.swift b/Sources/NetworkProtection/KeyManagement/NetworkProtectionTokenStore.swift index 57c8fb270..fbbb6cbe8 100644 --- a/Sources/NetworkProtection/KeyManagement/NetworkProtectionTokenStore.swift +++ b/Sources/NetworkProtection/KeyManagement/NetworkProtectionTokenStore.swift @@ -41,7 +41,8 @@ public final class NetworkProtectionKeychainTokenStore: NetworkProtectionTokenSt private let keychainStore: NetworkProtectionKeychainStore private let errorEvents: EventMapping? private let isSubscriptionEnabled: Bool - private let accessTokenProvider: () -> String? + public typealias AccessTokenProvider = () -> String? + private let accessTokenProvider: AccessTokenProvider public static var authTokenPrefix: String { "ddg:" } @@ -57,7 +58,7 @@ public final class NetworkProtectionKeychainTokenStore: NetworkProtectionTokenSt serviceName: String = Defaults.tokenStoreService, errorEvents: EventMapping?, isSubscriptionEnabled: Bool, - accessTokenProvider: @escaping () -> String?) { + accessTokenProvider: @escaping AccessTokenProvider) { keychainStore = NetworkProtectionKeychainStore(label: Defaults.tokenStoreEntryLabel, serviceName: serviceName, keychainType: keychainType) diff --git a/Sources/NetworkProtection/Repositories/NetworkProtectionLocationListRepository.swift b/Sources/NetworkProtection/Repositories/NetworkProtectionLocationListRepository.swift index 8897e782a..be741b8db 100644 --- a/Sources/NetworkProtection/Repositories/NetworkProtectionLocationListRepository.swift +++ b/Sources/NetworkProtection/Repositories/NetworkProtectionLocationListRepository.swift @@ -55,6 +55,7 @@ final public class NetworkProtectionLocationListCompositeRepository: NetworkProt } @MainActor + @discardableResult public func fetchLocationList() async throws -> [NetworkProtectionLocation] { guard !canUseCache else { return Self.locationList diff --git a/Sources/Subscription/AccountStorage/AccountKeychainStorage.swift b/Sources/Subscription/AccountStorage/AccountKeychainStorage.swift index 19596f764..4cc23a70d 100644 --- a/Sources/Subscription/AccountStorage/AccountKeychainStorage.swift +++ b/Sources/Subscription/AccountStorage/AccountKeychainStorage.swift @@ -48,57 +48,56 @@ public enum AccountKeychainAccessError: Error, Equatable { } } -public class AccountKeychainStorage: AccountStorage { +public class AccountKeychainStorage: AccountStoring { public init() {} public func getAuthToken() throws -> String? { - try Self.getString(forField: .authToken) + try getString(forField: .authToken) } public func store(authToken: String) throws { - try Self.set(string: authToken, forField: .authToken) + try set(string: authToken, forField: .authToken) } public func getAccessToken() throws -> String? { - try Self.getString(forField: .accessToken) + try getString(forField: .accessToken) } public func store(accessToken: String) throws { - try Self.set(string: accessToken, forField: .accessToken) + try set(string: accessToken, forField: .accessToken) } public func getEmail() throws -> String? { - try Self.getString(forField: .email) + try getString(forField: .email) } public func getExternalID() throws -> String? { - try Self.getString(forField: .externalID) + try getString(forField: .externalID) } public func store(externalID: String?) throws { if let externalID = externalID, !externalID.isEmpty { - try Self.set(string: externalID, forField: .externalID) + try set(string: externalID, forField: .externalID) } else { - try Self.deleteItem(forField: .externalID) + try deleteItem(forField: .externalID) } } public func store(email: String?) throws { if let email = email, !email.isEmpty { - try Self.set(string: email, forField: .email) + try set(string: email, forField: .email) } else { - try Self.deleteItem(forField: .email) + try deleteItem(forField: .email) } } public func clearAuthenticationState() throws { - try Self.deleteItem(forField: .authToken) - try Self.deleteItem(forField: .accessToken) - try Self.deleteItem(forField: .email) - try Self.deleteItem(forField: .externalID) + try deleteItem(forField: .authToken) + try deleteItem(forField: .accessToken) + try deleteItem(forField: .email) + try deleteItem(forField: .externalID) } - } private extension AccountKeychainStorage { @@ -118,7 +117,7 @@ private extension AccountKeychainStorage { } } - static func getString(forField field: AccountKeychainField) throws -> String? { + func getString(forField field: AccountKeychainField) throws -> String? { guard let data = try retrieveData(forField: field) else { return nil } @@ -130,7 +129,7 @@ private extension AccountKeychainStorage { } } - static func retrieveData(forField field: AccountKeychainField, useDataProtectionKeychain: Bool = true) throws -> Data? { + func retrieveData(forField field: AccountKeychainField, useDataProtectionKeychain: Bool = true) throws -> Data? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecMatchLimit as String: kSecMatchLimitOne, @@ -155,7 +154,7 @@ private extension AccountKeychainStorage { } } - static func set(string: String, forField field: AccountKeychainField) throws { + func set(string: String, forField field: AccountKeychainField) throws { guard let stringData = string.data(using: .utf8) else { return } @@ -164,7 +163,7 @@ private extension AccountKeychainStorage { try store(data: stringData, forField: field) } - static func store(data: Data, forField field: AccountKeychainField, useDataProtectionKeychain: Bool = true) throws { + func store(data: Data, forField field: AccountKeychainField, useDataProtectionKeychain: Bool = true) throws { let query = [ kSecClass: kSecClassGenericPassword, kSecAttrSynchronizable: false, @@ -180,7 +179,7 @@ private extension AccountKeychainStorage { } } - static func deleteItem(forField field: AccountKeychainField, useDataProtectionKeychain: Bool = true) throws { + func deleteItem(forField field: AccountKeychainField, useDataProtectionKeychain: Bool = true) throws { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: field.keyValue, diff --git a/Sources/Subscription/AccountStorage/AccountStorage.swift b/Sources/Subscription/AccountStorage/AccountStoring.swift similarity index 93% rename from Sources/Subscription/AccountStorage/AccountStorage.swift rename to Sources/Subscription/AccountStorage/AccountStoring.swift index 06b5e05cb..0968649bd 100644 --- a/Sources/Subscription/AccountStorage/AccountStorage.swift +++ b/Sources/Subscription/AccountStorage/AccountStoring.swift @@ -1,5 +1,5 @@ // -// AccountStorage.swift +// AccountStoring.swift // // Copyright © 2023 DuckDuckGo. All rights reserved. // @@ -18,7 +18,7 @@ import Foundation -public protocol AccountStorage: AnyObject { +public protocol AccountStoring: AnyObject { func getAuthToken() throws -> String? func store(authToken: String) throws func getAccessToken() throws -> String? diff --git a/Sources/Subscription/AccountStorage/SubscriptionTokenKeychainStorage.swift b/Sources/Subscription/AccountStorage/SubscriptionTokenKeychainStorage.swift index 9b7a86bcc..40f6442b8 100644 --- a/Sources/Subscription/AccountStorage/SubscriptionTokenKeychainStorage.swift +++ b/Sources/Subscription/AccountStorage/SubscriptionTokenKeychainStorage.swift @@ -18,7 +18,7 @@ import Foundation -public class SubscriptionTokenKeychainStorage: SubscriptionTokenStorage { +public class SubscriptionTokenKeychainStorage: SubscriptionTokenStoring { private let keychainType: KeychainType @@ -65,6 +65,7 @@ private extension SubscriptionTokenKeychainStorage { throw AccountKeychainAccessError.failedToDecodeKeychainDataAsString } } + func retrieveData(forField field: AccountKeychainField) throws -> Data? { var query = defaultAttributes() query[kSecAttrService] = field.keyValue @@ -153,11 +154,8 @@ private extension SubscriptionTokenKeychainStorage { public enum KeychainType { case dataProtection(_ accessGroup: AccessGroup) - /// Uses the system keychain. - /// case system - case fileBased public enum AccessGroup { diff --git a/Sources/Subscription/AccountStorage/SubscriptionTokenStorage.swift b/Sources/Subscription/AccountStorage/SubscriptionTokenStoring.swift similarity index 89% rename from Sources/Subscription/AccountStorage/SubscriptionTokenStorage.swift rename to Sources/Subscription/AccountStorage/SubscriptionTokenStoring.swift index 58c7e74b6..22e815b24 100644 --- a/Sources/Subscription/AccountStorage/SubscriptionTokenStorage.swift +++ b/Sources/Subscription/AccountStorage/SubscriptionTokenStoring.swift @@ -1,5 +1,5 @@ // -// SubscriptionTokenStorage.swift +// SubscriptionTokenStoring.swift // // Copyright © 2024 DuckDuckGo. All rights reserved. // @@ -18,7 +18,7 @@ import Foundation -public protocol SubscriptionTokenStorage: AnyObject { +public protocol SubscriptionTokenStoring: AnyObject { func getAccessToken() throws -> String? func store(accessToken: String) throws func removeAccessToken() throws diff --git a/Sources/Subscription/Flows/AppStore/AppStoreAccountManagementFlow.swift b/Sources/Subscription/Flows/AppStore/AppStoreAccountManagementFlow.swift index f94dba321..ab3cffe46 100644 --- a/Sources/Subscription/Flows/AppStore/AppStoreAccountManagementFlow.swift +++ b/Sources/Subscription/Flows/AppStore/AppStoreAccountManagementFlow.swift @@ -23,26 +23,33 @@ import Common @available(macOS 12.0, iOS 15.0, *) public final class AppStoreAccountManagementFlow { - public enum Error: Swift.Error { - case noPastTransaction - case authenticatingWithTransactionFailed - } + private let subscriptionManager: SubscriptionManaging + private var accountManager: AccountManaging { + subscriptionManager.accountManager + } + + public init(subscriptionManager: SubscriptionManaging) { + self.subscriptionManager = subscriptionManager + } + + public enum Error: Swift.Error { + case noPastTransaction + case authenticatingWithTransactionFailed + } @discardableResult - public static func refreshAuthTokenIfNeeded(subscriptionAppGroup: String) async -> Result { + public func refreshAuthTokenIfNeeded() async -> Result { os_log(.info, log: .subscription, "[AppStoreAccountManagementFlow] refreshAuthTokenIfNeeded") - let accountManager = AccountManager(subscriptionAppGroup: subscriptionAppGroup) - var authToken = accountManager.authToken ?? "" // Check if auth token if still valid - if case let .failure(validateTokenError) = await AuthService.validateToken(accessToken: authToken) { + if case let .failure(validateTokenError) = await subscriptionManager.authService.validateToken(accessToken: authToken) { os_log(.error, log: .subscription, "[AppStoreAccountManagementFlow] validateToken error: %{public}s", String(reflecting: validateTokenError)) // In case of invalid token attempt store based authentication to obtain a new one - guard let lastTransactionJWSRepresentation = await PurchaseManager.mostRecentTransaction() else { return .failure(.noPastTransaction) } + guard let lastTransactionJWSRepresentation = await subscriptionManager.storePurchaseManager().mostRecentTransaction() else { return .failure(.noPastTransaction) } - switch await AuthService.storeLogin(signature: lastTransactionJWSRepresentation) { + switch await subscriptionManager.authService.storeLogin(signature: lastTransactionJWSRepresentation) { case .success(let response): if response.externalID == accountManager.externalID { authToken = response.authToken diff --git a/Sources/Subscription/Flows/AppStore/AppStorePurchaseFlow.swift b/Sources/Subscription/Flows/AppStore/AppStorePurchaseFlow.swift index 6b078b96e..e3248a9f7 100644 --- a/Sources/Subscription/Flows/AppStore/AppStorePurchaseFlow.swift +++ b/Sources/Subscription/Flows/AppStore/AppStorePurchaseFlow.swift @@ -31,58 +31,34 @@ public final class AppStorePurchaseFlow { case purchaseFailed case cancelledByUser case missingEntitlements + case internalError } - public static func subscriptionOptions() async -> Result { - os_log(.info, log: .subscription, "[AppStorePurchaseFlow] subscriptionOptions") - - let products = PurchaseManager.shared.availableProducts - - let monthly = products.first(where: { $0.subscription?.subscriptionPeriod.unit == .month && $0.subscription?.subscriptionPeriod.value == 1 }) - let yearly = products.first(where: { $0.subscription?.subscriptionPeriod.unit == .year && $0.subscription?.subscriptionPeriod.value == 1 }) - - guard let monthly, let yearly else { - os_log(.error, log: .subscription, "[AppStorePurchaseFlow] Error: noProductsFound") - return .failure(.noProductsFound) - } - - let options = [SubscriptionOption(id: monthly.id, cost: .init(displayPrice: monthly.displayPrice, recurrence: "monthly")), - SubscriptionOption(id: yearly.id, cost: .init(displayPrice: yearly.displayPrice, recurrence: "yearly"))] - - let features = SubscriptionFeatureName.allCases.map { SubscriptionFeature(name: $0.rawValue) } - - let platform: SubscriptionPlatformName - -#if os(iOS) - platform = .ios -#else - platform = .macos -#endif + private let subscriptionManager: SubscriptionManaging + var accountManager: AccountManaging { + subscriptionManager.accountManager + } - return .success(SubscriptionOptions(platform: platform.rawValue, - options: options, - features: features)) + public init(subscriptionManager: SubscriptionManaging) { + self.subscriptionManager = subscriptionManager } public typealias TransactionJWS = String // swiftlint:disable cyclomatic_complexity - public static func purchaseSubscription(with subscriptionIdentifier: String, - emailAccessToken: String?, - subscriptionAppGroup: String) async -> Result { + public func purchaseSubscription(with subscriptionIdentifier: String, emailAccessToken: String?) async -> Result { os_log(.info, log: .subscription, "[AppStorePurchaseFlow] purchaseSubscription") - - let accountManager = AccountManager(subscriptionAppGroup: subscriptionAppGroup) let externalID: String // If the current account is a third party expired account, we want to purchase and attach subs to it - if let existingExternalID = await getExpiredSubscriptionID(accountManager: accountManager) { + if let existingExternalID = await getExpiredSubscriptionID() { externalID = existingExternalID // Otherwise, try to retrieve an expired Apple subscription or create a new one } else { // Check for past transactions most recent - switch await AppStoreRestoreFlow.restoreAccountFromPastPurchase(subscriptionAppGroup: subscriptionAppGroup) { + let appStoreRestoreFlow = AppStoreRestoreFlow(subscriptionManager: subscriptionManager) + switch await appStoreRestoreFlow.restoreAccountFromPastPurchase() { case .success: os_log(.info, log: .subscription, "[AppStorePurchaseFlow] purchaseSubscription -> restoreAccountFromPastPurchase: activeSubscriptionAlreadyPresent") return .failure(.activeSubscriptionAlreadyPresent) @@ -94,7 +70,7 @@ public final class AppStorePurchaseFlow { accountManager.storeAuthToken(token: expiredAccountDetails.authToken) accountManager.storeAccount(token: expiredAccountDetails.accessToken, email: expiredAccountDetails.email, externalID: expiredAccountDetails.externalID) default: - switch await AuthService.createAccount(emailAccessToken: emailAccessToken) { + switch await subscriptionManager.authService.createAccount(emailAccessToken: emailAccessToken) { case .success(let response): externalID = response.externalID @@ -112,12 +88,12 @@ public final class AppStorePurchaseFlow { } // Make the purchase - switch await PurchaseManager.shared.purchaseSubscription(with: subscriptionIdentifier, externalID: externalID) { + switch await subscriptionManager.storePurchaseManager().purchaseSubscription(with: subscriptionIdentifier, externalID: externalID) { case .success(let transactionJWS): return .success(transactionJWS) case .failure(let error): os_log(.error, log: .subscription, "[AppStorePurchaseFlow] purchaseSubscription error: %{public}s", String(reflecting: error)) - AccountManager(subscriptionAppGroup: subscriptionAppGroup).signOut(skipNotification: true) + accountManager.signOut(skipNotification: true) switch error { case .purchaseCancelledByUser: return .failure(.cancelledByUser) @@ -129,20 +105,19 @@ public final class AppStorePurchaseFlow { // swiftlint:enable cyclomatic_complexity @discardableResult - public static func completeSubscriptionPurchase(with transactionJWS: TransactionJWS, subscriptionAppGroup: String) async -> Result { + public func completeSubscriptionPurchase(with transactionJWS: TransactionJWS) async -> Result { // Clear subscription Cache - SubscriptionService.signOut() + subscriptionManager.subscriptionService.signOut() os_log(.info, log: .subscription, "[AppStorePurchaseFlow] completeSubscriptionPurchase") - let accountManager = AccountManager(subscriptionAppGroup: subscriptionAppGroup) guard let accessToken = accountManager.accessToken else { return .failure(.missingEntitlements) } let result = await callWithRetries(retry: 5, wait: 2.0) { - switch await SubscriptionService.confirmPurchase(accessToken: accessToken, signature: transactionJWS) { + switch await subscriptionManager.subscriptionService.confirmPurchase(accessToken: accessToken, signature: transactionJWS) { case .success(let confirmation): - SubscriptionService.updateCache(with: confirmation.subscription) + subscriptionManager.subscriptionService.updateCache(with: confirmation.subscription) accountManager.updateCache(with: confirmation.entitlements) return true case .failure: @@ -153,7 +128,7 @@ public final class AppStorePurchaseFlow { return result ? .success(PurchaseUpdate(type: "completed")) : .failure(.missingEntitlements) } - private static func callWithRetries(retry retryCount: Int, wait waitTime: Double, conditionToCheck: () async -> Bool) async -> Bool { + private func callWithRetries(retry retryCount: Int, wait waitTime: Double, conditionToCheck: () async -> Bool) async -> Bool { var count = 0 var successful = false @@ -171,13 +146,13 @@ public final class AppStorePurchaseFlow { return successful } - private static func getExpiredSubscriptionID(accountManager: AccountManager) async -> String? { + private func getExpiredSubscriptionID() async -> String? { guard accountManager.isUserAuthenticated, let externalID = accountManager.externalID, let token = accountManager.accessToken else { return nil } - let subscriptionInfo = await SubscriptionService.getSubscription(accessToken: token, cachePolicy: .reloadIgnoringLocalCacheData) + let subscriptionInfo = await subscriptionManager.subscriptionService.getSubscription(accessToken: token, cachePolicy: .reloadIgnoringLocalCacheData) // Only return an externalID if the subscription is expired // To prevent creating multiple subscriptions in the same account diff --git a/Sources/Subscription/Flows/AppStore/AppStoreRestoreFlow.swift b/Sources/Subscription/Flows/AppStore/AppStoreRestoreFlow.swift index b9006c845..c09afb7e1 100644 --- a/Sources/Subscription/Flows/AppStore/AppStoreRestoreFlow.swift +++ b/Sources/Subscription/Flows/AppStore/AppStoreRestoreFlow.swift @@ -34,24 +34,32 @@ public final class AppStoreRestoreFlow { case subscriptionExpired(accountDetails: RestoredAccountDetails) } - public static func restoreAccountFromPastPurchase(subscriptionAppGroup: String) async -> Result { + private let subscriptionManager: SubscriptionManaging + var accountManager: AccountManaging { + subscriptionManager.accountManager + } + + public init(subscriptionManager: SubscriptionManaging) { + self.subscriptionManager = subscriptionManager + } + + @discardableResult + public func restoreAccountFromPastPurchase() async -> Result { // Clear subscription Cache - SubscriptionService.signOut() + subscriptionManager.subscriptionService.signOut() os_log(.info, log: .subscription, "[AppStoreRestoreFlow] restoreAccountFromPastPurchase") - guard let lastTransactionJWSRepresentation = await PurchaseManager.mostRecentTransaction() else { + guard let lastTransactionJWSRepresentation = await subscriptionManager.storePurchaseManager().mostRecentTransaction() else { os_log(.error, log: .subscription, "[AppStoreRestoreFlow] Error: missingAccountOrTransactions") return .failure(.missingAccountOrTransactions) } - let accountManager = AccountManager(subscriptionAppGroup: subscriptionAppGroup) - // Do the store login to get short-lived token let authToken: String - switch await AuthService.storeLogin(signature: lastTransactionJWSRepresentation) { + switch await subscriptionManager.authService.storeLogin(signature: lastTransactionJWSRepresentation) { case .success(let response): authToken = response.authToken case .failure: @@ -82,7 +90,7 @@ public final class AppStoreRestoreFlow { var isSubscriptionActive = false - switch await SubscriptionService.getSubscription(accessToken: accessToken, cachePolicy: .reloadIgnoringLocalCacheData) { + switch await subscriptionManager.subscriptionService.getSubscription(accessToken: accessToken, cachePolicy: .reloadIgnoringLocalCacheData) { case .success(let subscription): isSubscriptionActive = subscription.isActive case .failure: diff --git a/Sources/Subscription/Flows/Models/PurchaseUpdate.swift b/Sources/Subscription/Flows/Models/PurchaseUpdate.swift new file mode 100644 index 000000000..74cc97996 --- /dev/null +++ b/Sources/Subscription/Flows/Models/PurchaseUpdate.swift @@ -0,0 +1,29 @@ +// +// PurchaseUpdate.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 Foundation + +public struct PurchaseUpdate: Codable { + let type: String + let token: String? + + public init(type: String, token: String? = nil) { + self.type = type + self.token = token + } +} diff --git a/Sources/Subscription/Flows/Models/SubscriptionEnvironmentNames.swift b/Sources/Subscription/Flows/Models/SubscriptionEnvironmentNames.swift new file mode 100644 index 000000000..540c0c2e8 --- /dev/null +++ b/Sources/Subscription/Flows/Models/SubscriptionEnvironmentNames.swift @@ -0,0 +1,35 @@ +// +// SubscriptionEnvironmentNames.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 Foundation + +public enum SubscriptionFeatureName: String, CaseIterable { + case privateBrowsing = "private-browsing" + case privateSearch = "private-search" + case emailProtection = "email-protection" + case appTrackingProtection = "app-tracking-protection" + case vpn = "vpn" + case personalInformationRemoval = "personal-information-removal" + case identityTheftRestoration = "identity-theft-restoration" +} + +public enum SubscriptionPlatformName: String { + case ios + case macos + case stripe +} diff --git a/Sources/Subscription/Flows/PurchaseFlow.swift b/Sources/Subscription/Flows/Models/SubscriptionOptions.swift similarity index 63% rename from Sources/Subscription/Flows/PurchaseFlow.swift rename to Sources/Subscription/Flows/Models/SubscriptionOptions.swift index e9ad1bffa..f75d78f67 100644 --- a/Sources/Subscription/Flows/PurchaseFlow.swift +++ b/Sources/Subscription/Flows/Models/SubscriptionOptions.swift @@ -1,7 +1,7 @@ // -// PurchaseFlow.swift +// SubscriptionOptions.swift // -// Copyright © 2023 DuckDuckGo. All rights reserved. +// 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. @@ -47,33 +47,3 @@ struct SubscriptionOptionCost: Encodable { public struct SubscriptionFeature: Encodable { let name: String } - -// MARK: - - -public enum SubscriptionFeatureName: String, CaseIterable { - case privateBrowsing = "private-browsing" - case privateSearch = "private-search" - case emailProtection = "email-protection" - case appTrackingProtection = "app-tracking-protection" - case vpn = "vpn" - case personalInformationRemoval = "personal-information-removal" - case identityTheftRestoration = "identity-theft-restoration" -} - -public enum SubscriptionPlatformName: String { - case ios - case macos - case stripe -} - -// MARK: - - -public struct PurchaseUpdate: Codable { - let type: String - let token: String? - - public init(type: String, token: String? = nil) { - self.type = type - self.token = token - } -} diff --git a/Sources/Subscription/Flows/Stripe/StripePurchaseFlow.swift b/Sources/Subscription/Flows/Stripe/StripePurchaseFlow.swift index 5ed6d8800..24c3021ee 100644 --- a/Sources/Subscription/Flows/Stripe/StripePurchaseFlow.swift +++ b/Sources/Subscription/Flows/Stripe/StripePurchaseFlow.swift @@ -22,15 +22,24 @@ import Common public final class StripePurchaseFlow { + private let subscriptionManager: SubscriptionManaging + var accountManager: AccountManaging { + subscriptionManager.accountManager + } + + public init(subscriptionManager: SubscriptionManaging) { + self.subscriptionManager = subscriptionManager + } + public enum Error: Swift.Error { case noProductsFound case accountCreationFailed } - public static func subscriptionOptions() async -> Result { + public func subscriptionOptions() async -> Result { os_log(.info, log: .subscription, "[StripePurchaseFlow] subscriptionOptions") - guard case let .success(products) = await SubscriptionService.getProducts(), !products.isEmpty else { + guard case let .success(products) = await subscriptionManager.subscriptionService.getProducts(), !products.isEmpty else { os_log(.error, log: .subscription, "[StripePurchaseFlow] Error: noProductsFound") return .failure(.noProductsFound) } @@ -61,13 +70,11 @@ public final class StripePurchaseFlow { features: features)) } - public static func prepareSubscriptionPurchase(emailAccessToken: String?, subscriptionAppGroup: String) async -> Result { + public func prepareSubscriptionPurchase(emailAccessToken: String?) async -> Result { os_log(.info, log: .subscription, "[StripePurchaseFlow] prepareSubscriptionPurchase") // Clear subscription Cache - SubscriptionService.signOut() - let accountManager = AccountManager(subscriptionAppGroup: subscriptionAppGroup) - + subscriptionManager.subscriptionService.signOut() var token: String = "" if let accessToken = accountManager.accessToken { @@ -75,10 +82,10 @@ public final class StripePurchaseFlow { token = accessToken } } else { - switch await AuthService.createAccount(emailAccessToken: emailAccessToken) { + switch await subscriptionManager.authService.createAccount(emailAccessToken: emailAccessToken) { case .success(let response): token = response.authToken - AccountManager(subscriptionAppGroup: subscriptionAppGroup).storeAuthToken(token: token) + accountManager.storeAuthToken(token: token) case .failure: os_log(.error, log: .subscription, "[StripePurchaseFlow] Error: accountCreationFailed") return .failure(.accountCreationFailed) @@ -88,23 +95,20 @@ public final class StripePurchaseFlow { return .success(PurchaseUpdate(type: "redirect", token: token)) } - private static func isSubscriptionExpired(accessToken: String) async -> Bool { - if case .success(let subscription) = await SubscriptionService.getSubscription(accessToken: accessToken) { + private func isSubscriptionExpired(accessToken: String) async -> Bool { + if case .success(let subscription) = await subscriptionManager.subscriptionService.getSubscription(accessToken: accessToken) { return !subscription.isActive } return false } - public static func completeSubscriptionPurchase(subscriptionAppGroup: String) async { + public func completeSubscriptionPurchase() async { // Clear subscription Cache - SubscriptionService.signOut() + subscriptionManager.subscriptionService.signOut() os_log(.info, log: .subscription, "[StripePurchaseFlow] completeSubscriptionPurchase") - - let accountManager = AccountManager(subscriptionAppGroup: subscriptionAppGroup) - if !accountManager.isUserAuthenticated, let authToken = accountManager.authToken { if case let .success(accessToken) = await accountManager.exchangeAuthTokenToAccessToken(authToken), @@ -114,6 +118,6 @@ public final class StripePurchaseFlow { } } - await AccountManager.checkForEntitlements(subscriptionAppGroup: subscriptionAppGroup, wait: 2.0, retry: 5) + await accountManager.checkForEntitlements(wait: 2.0, retry: 5) } } diff --git a/Sources/Subscription/NSNotificationName+Subscription.swift b/Sources/Subscription/NSNotificationName+Subscription.swift index 20fc2f7b1..1079c6231 100644 --- a/Sources/Subscription/NSNotificationName+Subscription.swift +++ b/Sources/Subscription/NSNotificationName+Subscription.swift @@ -27,4 +27,9 @@ public extension NSNotification.Name { static let openVPN = Notification.Name("com.duckduckgo.subscription.open.vpn") static let openPersonalInformationRemoval = Notification.Name("com.duckduckgo.subscription.open.personal-information-removal") static let openIdentityTheftRestoration = Notification.Name("com.duckduckgo.subscription.open.identity-theft-restoration") + + static let accountDidSignIn = Notification.Name("com.duckduckgo.subscription.AccountDidSignIn") + static let accountDidSignOut = Notification.Name("com.duckduckgo.subscription.AccountDidSignOut") + static let entitlementsDidChange = Notification.Name("com.duckduckgo.subscription.EntitlementsDidChange") + static let subscriptionDidChange = Notification.Name("com.duckduckgo.subscription.SubscriptionDidChange") } diff --git a/Sources/Subscription/Services/APIService.swift b/Sources/Subscription/Services/APIService.swift index 4d79e574b..b1f299676 100644 --- a/Sources/Subscription/Services/APIService.swift +++ b/Sources/Subscription/Services/APIService.swift @@ -32,14 +32,14 @@ struct ErrorResponse: Decodable { } public protocol APIService { - static var baseURL: URL { get } - static var session: URLSession { get } - static func executeAPICall(method: String, endpoint: String, headers: [String: String]?, body: Data?) async -> Result where T: Decodable + var baseURL: URL { get } + var session: URLSession { get } + func executeAPICall(method: String, endpoint: String, headers: [String: String]?, body: Data?) async -> Result where T: Decodable } public extension APIService { - static func executeAPICall(method: String, endpoint: String, headers: [String: String]? = nil, body: Data? = nil) async -> Result where T: Decodable { + func executeAPICall(method: String, endpoint: String, headers: [String: String]? = nil, body: Data? = nil) async -> Result where T: Decodable { let request = makeAPIRequest(method: method, endpoint: endpoint, headers: headers, body: body) do { @@ -70,7 +70,7 @@ public extension APIService { } } - private static func makeAPIRequest(method: String, endpoint: String, headers: [String: String]?, body: Data?) -> URLRequest { + private func makeAPIRequest(method: String, endpoint: String, headers: [String: String]?, body: Data?) -> URLRequest { let url = baseURL.appendingPathComponent(endpoint) var request = URLRequest(url: url) request.httpMethod = method @@ -84,7 +84,7 @@ public extension APIService { return request } - private static func decode(_: T.Type, from data: Data) -> T? where T: Decodable { + private func decode(_: T.Type, from data: Data) -> T? where T: Decodable { let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase decoder.dateDecodingStrategy = .millisecondsSince1970 @@ -92,19 +92,19 @@ public extension APIService { return try? decoder.decode(T.self, from: data) } - private static func printDebugInfo(method: String, endpoint: String, data: Data, response: URLResponse) { + private func printDebugInfo(method: String, endpoint: String, data: Data, response: URLResponse) { let statusCode = (response as? HTTPURLResponse)!.statusCode let stringData = String(data: data, encoding: .utf8) ?? "" os_log(.info, log: .subscription, "[API] %d %{public}s /%{public}s :: %{public}s", statusCode, method, endpoint, stringData) } - static func makeAuthorizationHeader(for token: String) -> [String: String] { + func makeAuthorizationHeader(for token: String) -> [String: String] { ["Authorization": "Bearer " + token] } } -extension URLResponse { +fileprivate extension URLResponse { var httpStatusCodeAsString: String? { guard let httpStatusCode = (self as? HTTPURLResponse)?.statusCode else { return nil } diff --git a/Sources/Subscription/Services/AuthService.swift b/Sources/Subscription/Services/AuthService.swift index b2e337a99..d35ac3b0c 100644 --- a/Sources/Subscription/Services/AuthService.swift +++ b/Sources/Subscription/Services/AuthService.swift @@ -16,18 +16,24 @@ // limitations under the License. // -import Common import Foundation +import Common public struct AuthService: APIService { - public static let session = { + private let currentServiceEnvironment: SubscriptionEnvironment.ServiceEnvironment + + public init(currentServiceEnvironment: SubscriptionEnvironment.ServiceEnvironment) { + self.currentServiceEnvironment = currentServiceEnvironment + } + + public let session = { let configuration = URLSessionConfiguration.ephemeral return URLSession(configuration: configuration) }() - public static var baseURL: URL { - switch SubscriptionPurchaseEnvironment.currentServiceEnvironment { + public var baseURL: URL { + switch currentServiceEnvironment { case .production: URL(string: "https://quack.duckduckgo.com/api/auth")! case .staging: @@ -37,7 +43,7 @@ public struct AuthService: APIService { // MARK: - - public static func getAccessToken(token: String) async -> Result { + public func getAccessToken(token: String) async -> Result { await executeAPICall(method: "GET", endpoint: "access-token", headers: makeAuthorizationHeader(for: token)) } @@ -47,11 +53,10 @@ public struct AuthService: APIService { // MARK: - - public static func validateToken(accessToken: String) async -> Result { + public func validateToken(accessToken: String) async -> Result { await executeAPICall(method: "GET", endpoint: "validate-token", headers: makeAuthorizationHeader(for: accessToken)) } - // swiftlint:disable nesting public struct ValidateTokenResponse: Decodable { public let account: Account @@ -65,11 +70,10 @@ public struct AuthService: APIService { } } } - // swiftlint:enable nesting // MARK: - - public static func createAccount(emailAccessToken: String?) async -> Result { + public func createAccount(emailAccessToken: String?) async -> Result { var headers: [String: String]? if let emailAccessToken { @@ -91,7 +95,7 @@ public struct AuthService: APIService { // MARK: - - public static func storeLogin(signature: String) async -> Result { + public func storeLogin(signature: String) async -> Result { let bodyDict = ["signature": signature, "store": "apple_app_store"] diff --git a/Sources/Subscription/Services/Model/Subscription.swift b/Sources/Subscription/Services/Model/Subscription.swift index 703b1617a..3dc9f8807 100644 --- a/Sources/Subscription/Services/Model/Subscription.swift +++ b/Sources/Subscription/Services/Model/Subscription.swift @@ -23,10 +23,10 @@ public typealias DDGSubscription = Subscription // to avoid conflicts when Combi public struct Subscription: Codable, Equatable { public let productId: String public let name: String - public let billingPeriod: BillingPeriod + public let billingPeriod: Subscription.BillingPeriod public let startedAt: Date public let expiresOrRenewsAt: Date - public let platform: Platform + public let platform: Subscription.Platform public let status: Status public enum BillingPeriod: String, Codable { diff --git a/Sources/Subscription/Services/SubscriptionService.swift b/Sources/Subscription/Services/SubscriptionService.swift index 6c7c9be17..978eb3406 100644 --- a/Sources/Subscription/Services/SubscriptionService.swift +++ b/Sources/Subscription/Services/SubscriptionService.swift @@ -19,15 +19,22 @@ import Common import Foundation +/// Communicates with our backend public final class SubscriptionService: APIService { - public static let session = { + private let currentServiceEnvironment: SubscriptionEnvironment.ServiceEnvironment + + public init(currentServiceEnvironment: SubscriptionEnvironment.ServiceEnvironment) { + self.currentServiceEnvironment = currentServiceEnvironment + } + + public let session = { let configuration = URLSessionConfiguration.ephemeral return URLSession(configuration: configuration) }() - public static var baseURL: URL { - switch SubscriptionPurchaseEnvironment.currentServiceEnvironment { + public var baseURL: URL { + switch currentServiceEnvironment { case .production: URL(string: "https://subscriptions.duckduckgo.com/api")! case .staging: @@ -35,8 +42,8 @@ public final class SubscriptionService: APIService { } } - private static let subscriptionCache = UserDefaultsCache(key: UserDefaultsCacheKey.subscription, - settings: UserDefaultsCacheSettings(defaultExpirationInterval: .minutes(20))) + private let subscriptionCache = UserDefaultsCache(key: UserDefaultsCacheKey.subscription, + settings: UserDefaultsCacheSettings(defaultExpirationInterval: .minutes(20))) public enum CachePolicy { case reloadIgnoringLocalCacheData @@ -51,9 +58,9 @@ public final class SubscriptionService: APIService { // MARK: - Subscription fetching with caching - private static func getRemoteSubscription(accessToken: String) async -> Result { - let result: Result = await executeAPICall(method: "GET", endpoint: "subscription", headers: makeAuthorizationHeader(for: accessToken)) + private func getRemoteSubscription(accessToken: String) async -> Result { + let result: Result = await executeAPICall(method: "GET", endpoint: "subscription", headers: makeAuthorizationHeader(for: accessToken)) switch result { case .success(let subscriptionResponse): updateCache(with: subscriptionResponse) @@ -63,9 +70,9 @@ public final class SubscriptionService: APIService { } } - public static func updateCache(with subscription: Subscription) { - let cachedSubscription: Subscription? = subscriptionCache.get() + public func updateCache(with subscription: Subscription) { + let cachedSubscription: Subscription? = subscriptionCache.get() if subscription != cachedSubscription { let defaultExpiryDate = Date().addingTimeInterval(subscriptionCache.settings.defaultExpirationInterval) let expiryDate = min(defaultExpiryDate, subscription.expiresOrRenewsAt) @@ -75,7 +82,7 @@ public final class SubscriptionService: APIService { } } - public static func getSubscription(accessToken: String, cachePolicy: CachePolicy = .returnCacheDataElseLoad) async -> Result { + public func getSubscription(accessToken: String, cachePolicy: CachePolicy = .returnCacheDataElseLoad) async -> Result { switch cachePolicy { case .reloadIgnoringLocalCacheData: @@ -97,20 +104,16 @@ public final class SubscriptionService: APIService { } } - public static func signOut() { + public func signOut() { subscriptionCache.reset() } - public typealias GetSubscriptionResponse = Subscription - // MARK: - - public static func getProducts() async -> Result { + public func getProducts() async -> Result<[GetProductsItem], APIServiceError> { await executeAPICall(method: "GET", endpoint: "products") } - public typealias GetProductsResponse = [GetProductsItem] - public struct GetProductsItem: Decodable { public let productId: String public let productLabel: String @@ -121,7 +124,7 @@ public final class SubscriptionService: APIService { // MARK: - - public static func getCustomerPortalURL(accessToken: String, externalID: String) async -> Result { + public func getCustomerPortalURL(accessToken: String, externalID: String) async -> Result { var headers = makeAuthorizationHeader(for: accessToken) headers["externalAccountId"] = externalID return await executeAPICall(method: "GET", endpoint: "checkout/portal", headers: headers) @@ -133,7 +136,7 @@ public final class SubscriptionService: APIService { // MARK: - - public static func confirmPurchase(accessToken: String, signature: String) async -> Result { + public func confirmPurchase(accessToken: String, signature: String) async -> Result { let headers = makeAuthorizationHeader(for: accessToken) let bodyDict = ["signedTransactionInfo": signature] diff --git a/Sources/Subscription/AccountManager.swift b/Sources/Subscription/SubManagers/AccountManager.swift similarity index 77% rename from Sources/Subscription/AccountManager.swift rename to Sources/Subscription/SubManagers/AccountManager.swift index 4096b916a..7055005fc 100644 --- a/Sources/Subscription/AccountManager.swift +++ b/Sources/Subscription/SubManagers/AccountManager.swift @@ -19,64 +19,33 @@ import Foundation import Common -public extension Notification.Name { - static let accountDidSignIn = Notification.Name("com.duckduckgo.subscription.AccountDidSignIn") - static let accountDidSignOut = Notification.Name("com.duckduckgo.subscription.AccountDidSignOut") - static let entitlementsDidChange = Notification.Name("com.duckduckgo.subscription.EntitlementsDidChange") - static let subscriptionDidChange = Notification.Name("com.duckduckgo.subscription.SubscriptionDidChange") -} - -public protocol AccountManagerKeychainAccessDelegate: AnyObject { - func accountManagerKeychainAccessFailed(accessType: AccountKeychainAccessType, error: AccountKeychainAccessError) -} - -public protocol AccountManaging { - - var accessToken: String? { get } - -} - public class AccountManager: AccountManaging { - public enum CachePolicy { - case reloadIgnoringLocalCacheData - case returnCacheDataElseLoad - case returnCacheDataDontLoad - } - - private let storage: AccountStorage + private let storage: AccountStoring private let entitlementsCache: UserDefaultsCache<[Entitlement]> - private let accessTokenStorage: SubscriptionTokenStorage + private let accessTokenStorage: SubscriptionTokenStoring + private let subscriptionService: SubscriptionService + private let authService: AuthService public weak var delegate: AccountManagerKeychainAccessDelegate? + public var isUserAuthenticated: Bool { accessToken != nil } - public var isUserAuthenticated: Bool { - return accessToken != nil - } + // MARK: - Initialisers - public convenience init(subscriptionAppGroup: String?, accessTokenStorage: SubscriptionTokenStorage) { - self.init(accessTokenStorage: accessTokenStorage, - entitlementsCache: UserDefaultsCache<[Entitlement]>(userDefaults: UserDefaults(suiteName: subscriptionAppGroup) ?? UserDefaults.standard, - key: UserDefaultsCacheKey.subscriptionEntitlements, - settings: UserDefaultsCacheSettings(defaultExpirationInterval: .minutes(20)))) - } - - public convenience init(subscriptionAppGroup: String) { - let accessTokenStorage = SubscriptionTokenKeychainStorage(keychainType: .dataProtection(.named(subscriptionAppGroup))) - self.init(accessTokenStorage: accessTokenStorage, - entitlementsCache: UserDefaultsCache<[Entitlement]>(userDefaults: UserDefaults(suiteName: subscriptionAppGroup) ?? UserDefaults.standard, - key: UserDefaultsCacheKey.subscriptionEntitlements, - settings: UserDefaultsCacheSettings(defaultExpirationInterval: .minutes(20)))) - } - - public init(storage: AccountStorage = AccountKeychainStorage(), - accessTokenStorage: SubscriptionTokenStorage, - entitlementsCache: UserDefaultsCache<[Entitlement]>) { + public init(storage: AccountStoring = AccountKeychainStorage(), + accessTokenStorage: SubscriptionTokenStoring, + entitlementsCache: UserDefaultsCache<[Entitlement]>, + subscriptionService: SubscriptionService, + authService: AuthService) { self.storage = storage self.entitlementsCache = entitlementsCache self.accessTokenStorage = accessTokenStorage + self.subscriptionService = subscriptionService + self.authService = authService } + // MARK: - + public var authToken: String? { do { return try storage.getAuthToken() @@ -182,13 +151,17 @@ public class AccountManager: AccountManaging { NotificationCenter.default.post(name: .accountDidSignIn, object: self, userInfo: nil) } + public func signOut() { + signOut(skipNotification: false) + } + public func signOut(skipNotification: Bool = false) { os_log(.info, log: .subscription, "[AccountManager] signOut") do { try storage.clearAuthenticationState() try accessTokenStorage.removeAccessToken() - SubscriptionService.signOut() + subscriptionService.signOut() entitlementsCache.reset() } catch { if let error = error as? AccountKeychainAccessError { @@ -241,13 +214,17 @@ public class AccountManager: AccountManaging { } } + public func hasEntitlement(for entitlement: Entitlement.ProductName) async -> Result { + return await hasEntitlement(for: entitlement, cachePolicy: .returnCacheDataElseLoad) + } + private func fetchRemoteEntitlements() async -> Result<[Entitlement], Error> { guard let accessToken else { entitlementsCache.reset() return .failure(EntitlementsError.noAccessToken) } - switch await AuthService.validateToken(accessToken: accessToken) { + switch await authService.validateToken(accessToken: accessToken) { case .success(let response): let entitlements = response.account.entitlements updateCache(with: entitlements) @@ -272,6 +249,7 @@ public class AccountManager: AccountManaging { } } + @discardableResult public func fetchEntitlements(cachePolicy: CachePolicy = .returnCacheDataElseLoad) async -> Result<[Entitlement], Error> { switch cachePolicy { @@ -296,7 +274,7 @@ public class AccountManager: AccountManaging { } public func exchangeAuthTokenToAccessToken(_ authToken: String) async -> Result { - switch await AuthService.getAccessToken(token: authToken) { + switch await authService.getAccessToken(token: authToken) { case .success(let response): return .success(response.accessToken) case .failure(let error): @@ -305,10 +283,8 @@ public class AccountManager: AccountManaging { } } - public typealias AccountDetails = (email: String?, externalID: String) - public func fetchAccountDetails(with accessToken: String) async -> Result { - switch await AuthService.validateToken(accessToken: accessToken) { + switch await authService.validateToken(accessToken: accessToken) { case .success(let response): return .success(AccountDetails(email: response.account.email, externalID: response.account.externalID)) case .failure(let error): @@ -321,27 +297,27 @@ public class AccountManager: AccountManaging { os_log(.info, log: .subscription, "[AccountManager] refreshSubscriptionAndEntitlements") guard let token = accessToken else { - SubscriptionService.signOut() + subscriptionService.signOut() entitlementsCache.reset() return } - if case .success(let subscription) = await SubscriptionService.getSubscription(accessToken: token, cachePolicy: .reloadIgnoringLocalCacheData) { + if case .success(let subscription) = await subscriptionService.getSubscription(accessToken: token, cachePolicy: .reloadIgnoringLocalCacheData) { if !subscription.isActive { signOut() } } - _ = await fetchEntitlements(cachePolicy: .reloadIgnoringLocalCacheData) + await fetchEntitlements(cachePolicy: .reloadIgnoringLocalCacheData) } @discardableResult - public static func checkForEntitlements(subscriptionAppGroup: String, wait waitTime: Double, retry retryCount: Int) async -> Bool { + public func checkForEntitlements(wait waitTime: Double, retry retryCount: Int) async -> Bool { var count = 0 var hasEntitlements = false repeat { - switch await AccountManager(subscriptionAppGroup: subscriptionAppGroup).fetchEntitlements() { + switch await fetchEntitlements() { case .success(let entitlements): hasEntitlements = !entitlements.isEmpty case .failure: diff --git a/Sources/Subscription/SubManagers/AccountManaging.swift b/Sources/Subscription/SubManagers/AccountManaging.swift new file mode 100644 index 000000000..673860ef7 --- /dev/null +++ b/Sources/Subscription/SubManagers/AccountManaging.swift @@ -0,0 +1,58 @@ +// +// AccountManaging.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 Foundation + +public protocol AccountManagerKeychainAccessDelegate: AnyObject { + func accountManagerKeychainAccessFailed(accessType: AccountKeychainAccessType, error: AccountKeychainAccessError) +} + +public enum AccountManagingCachePolicy { + case reloadIgnoringLocalCacheData + case returnCacheDataElseLoad + case returnCacheDataDontLoad +} + +public protocol AccountManaging { + + var delegate: AccountManagerKeychainAccessDelegate? { get set } + var isUserAuthenticated: Bool { get } + var accessToken: String? { get } + var authToken: String? { get } + var email: String? { get } + var externalID: String? { get } + + func storeAuthToken(token: String) + func storeAccount(token: String, email: String?, externalID: String?) + func signOut(skipNotification: Bool) + func signOut() + func migrateAccessTokenToNewStore() throws + + // Entitlements + typealias CachePolicy = AccountManagingCachePolicy + func hasEntitlement(for entitlement: Entitlement.ProductName, cachePolicy: CachePolicy) async -> Result + func hasEntitlement(for entitlement: Entitlement.ProductName) async -> Result + func updateCache(with entitlements: [Entitlement]) + @discardableResult func fetchEntitlements(cachePolicy: CachePolicy) async -> Result<[Entitlement], Error> + func exchangeAuthTokenToAccessToken(_ authToken: String) async -> Result + + typealias AccountDetails = (email: String?, externalID: String) + func fetchAccountDetails(with accessToken: String) async -> Result + func refreshSubscriptionAndEntitlements() async + @discardableResult func checkForEntitlements(wait waitTime: Double, retry retryCount: Int) async -> Bool +} diff --git a/Sources/Subscription/PurchaseManager.swift b/Sources/Subscription/SubManagers/StorePurchaseManager.swift similarity index 57% rename from Sources/Subscription/PurchaseManager.swift rename to Sources/Subscription/SubManagers/StorePurchaseManager.swift index d0555c027..2b5e0bf5b 100644 --- a/Sources/Subscription/PurchaseManager.swift +++ b/Sources/Subscription/SubManagers/StorePurchaseManager.swift @@ -1,5 +1,5 @@ // -// PurchaseManager.swift +// StorePurchaseManager.swift // // Copyright © 2023 DuckDuckGo. All rights reserved. // @@ -24,36 +24,23 @@ import Common @available(macOS 12.0, iOS 15.0, *) typealias RenewalInfo = StoreKit.Product.SubscriptionInfo.RenewalInfo @available(macOS 12.0, iOS 15.0, *) typealias RenewalState = StoreKit.Product.SubscriptionInfo.RenewalState -public enum StoreError: Error { - case failedVerification -} - -public enum PurchaseManagerError: Error { - case productNotFound - case externalIDisNotAValidUUID - case purchaseFailed - case transactionCannotBeVerified - case transactionPendingAuthentication - case purchaseCancelledByUser - case unknownError -} - @available(macOS 12.0, iOS 15.0, *) -public final class PurchaseManager: ObservableObject { - - static let productIdentifiers = ["ios.subscription.1month", "ios.subscription.1year", - "subscription.1month", "subscription.1year", - "review.subscription.1month", "review.subscription.1year", - "tf.sandbox.subscription.1month", "tf.sandbox.subscription.1year", - "ddg.privacy.pro.monthly.renews.us", "ddg.privacy.pro.yearly.renews.us"] +public final class StorePurchaseManager: ObservableObject, StorePurchaseManaging { - public static let shared = PurchaseManager() + let productIdentifiers = ["ios.subscription.1month", "ios.subscription.1year", + "subscription.1month", "subscription.1year", + "review.subscription.1month", "review.subscription.1year", + "tf.sandbox.subscription.1month", "tf.sandbox.subscription.1year", + "ddg.privacy.pro.monthly.renews.us", "ddg.privacy.pro.yearly.renews.us"] @Published public private(set) var availableProducts: [Product] = [] @Published public private(set) var purchasedProductIDs: [String] = [] @Published public private(set) var purchaseQueue: [String] = [] + @Published private var subscriptionGroupStatus: RenewalState? - @Published private(set) var subscriptionGroupStatus: RenewalState? + public var areProductsAvailable: Bool { + !availableProducts.isEmpty + } private var transactionUpdates: Task? private var storefrontChanges: Task? @@ -74,41 +61,66 @@ public final class PurchaseManager: ObservableObject { do { purchaseQueue.removeAll() - os_log(.info, log: .subscription, "[PurchaseManager] Before AppStore.sync()") + os_log(.info, log: .subscription, "[StorePurchaseManager] Before AppStore.sync()") try await AppStore.sync() - os_log(.info, log: .subscription, "[PurchaseManager] After AppStore.sync()") + os_log(.info, log: .subscription, "[StorePurchaseManager] After AppStore.sync()") await updatePurchasedProducts() await updateAvailableProducts() return .success(()) } catch { - os_log(.error, log: .subscription, "[PurchaseManager] Error: %{public}s (%{public}s)", String(reflecting: error), error.localizedDescription) + os_log(.error, log: .subscription, "[StorePurchaseManager] Error: %{public}s (%{public}s)", String(reflecting: error), error.localizedDescription) return .failure(error) } } + public func subscriptionOptions() async -> SubscriptionOptions? { + os_log(.info, log: .subscription, "[AppStorePurchaseFlow] subscriptionOptions") + let products = availableProducts + let monthly = products.first(where: { $0.subscription?.subscriptionPeriod.unit == .month && $0.subscription?.subscriptionPeriod.value == 1 }) + let yearly = products.first(where: { $0.subscription?.subscriptionPeriod.unit == .year && $0.subscription?.subscriptionPeriod.value == 1 }) + guard let monthly, let yearly else { + os_log(.error, log: .subscription, "[AppStorePurchaseFlow] No products found") + return nil + } + + let options = [SubscriptionOption(id: monthly.id, cost: .init(displayPrice: monthly.displayPrice, recurrence: "monthly")), + SubscriptionOption(id: yearly.id, cost: .init(displayPrice: yearly.displayPrice, recurrence: "yearly"))] + let features = SubscriptionFeatureName.allCases.map { SubscriptionFeature(name: $0.rawValue) } + let platform: SubscriptionPlatformName + +#if os(iOS) + platform = .ios +#else + platform = .macos +#endif + return SubscriptionOptions(platform: platform.rawValue, + options: options, + features: features) + } + @MainActor public func updateAvailableProducts() async { - os_log(.info, log: .subscription, "[PurchaseManager] updateAvailableProducts") + os_log(.info, log: .subscription, "[StorePurchaseManager] updateAvailableProducts") do { - let availableProducts = try await Product.products(for: Self.productIdentifiers) - os_log(.info, log: .subscription, "[PurchaseManager] updateAvailableProducts fetched %d products", availableProducts.count) + let availableProducts = try await Product.products(for: productIdentifiers) + os_log(.info, log: .subscription, "[StorePurchaseManager] updateAvailableProducts fetched %d products", availableProducts.count) if self.availableProducts != availableProducts { self.availableProducts = availableProducts } } catch { - os_log(.error, log: .subscription, "[PurchaseManager] Error: %{public}s", String(reflecting: error)) + os_log(.error, log: .subscription, "[StorePurchaseManager] Error: %{public}s", String(reflecting: error)) } } @MainActor public func updatePurchasedProducts() async { - os_log(.info, log: .subscription, "[PurchaseManager] updatePurchasedProducts") + os_log(.info, log: .subscription, "[StorePurchaseManager] updatePurchasedProducts") var purchasedSubscriptions: [String] = [] @@ -124,10 +136,10 @@ public final class PurchaseManager: ObservableObject { } } } catch { - os_log(.error, log: .subscription, "[PurchaseManager] Error: %{public}s", String(reflecting: error)) + os_log(.error, log: .subscription, "[StorePurchaseManager] Error: %{public}s", String(reflecting: error)) } - os_log(.info, log: .subscription, "[PurchaseManager] updatePurchasedProducts fetched %d active subscriptions", purchasedSubscriptions.count) + os_log(.info, log: .subscription, "[StorePurchaseManager] updatePurchasedProducts fetched %d active subscriptions", purchasedSubscriptions.count) if self.purchasedProductIDs != purchasedSubscriptions { self.purchasedProductIDs = purchasedSubscriptions @@ -137,8 +149,8 @@ public final class PurchaseManager: ObservableObject { } @MainActor - public static func mostRecentTransaction() async -> String? { - os_log(.info, log: .subscription, "[PurchaseManager] mostRecentTransaction") + public func mostRecentTransaction() async -> String? { + os_log(.info, log: .subscription, "[StorePurchaseManager] mostRecentTransaction") var transactions: [VerificationResult] = [] @@ -146,14 +158,14 @@ public final class PurchaseManager: ObservableObject { transactions.append(result) } - os_log(.info, log: .subscription, "[PurchaseManager] mostRecentTransaction fetched %d transactions", transactions.count) + os_log(.info, log: .subscription, "[StorePurchaseManager] mostRecentTransaction fetched %d transactions", transactions.count) return transactions.first?.jwsRepresentation } @MainActor - public static func hasActiveSubscription() async -> Bool { - os_log(.info, log: .subscription, "[PurchaseManager] hasActiveSubscription") + public func hasActiveSubscription() async -> Bool { + os_log(.info, log: .subscription, "[StorePurchaseManager] hasActiveSubscription") var transactions: [VerificationResult] = [] @@ -161,7 +173,7 @@ public final class PurchaseManager: ObservableObject { transactions.append(result) } - os_log(.info, log: .subscription, "[PurchaseManager] hasActiveSubscription fetched %d transactions", transactions.count) + os_log(.info, log: .subscription, "[StorePurchaseManager] hasActiveSubscription fetched %d transactions", transactions.count) return !transactions.isEmpty } @@ -173,7 +185,7 @@ public final class PurchaseManager: ObservableObject { guard let product = availableProducts.first(where: { $0.id == identifier }) else { return .failure(PurchaseManagerError.productNotFound) } - os_log(.info, log: .subscription, "[PurchaseManager] purchaseSubscription %{public}s (%{public}s)", product.displayName, externalID) + os_log(.info, log: .subscription, "[StorePurchaseManager] purchaseSubscription %{public}s (%{public}s)", product.displayName, externalID) purchaseQueue.append(product.id) @@ -182,7 +194,7 @@ public final class PurchaseManager: ObservableObject { if let token = UUID(uuidString: externalID) { options.insert(.appAccountToken(token)) } else { - os_log(.error, log: .subscription, "[PurchaseManager] Error: Failed to create UUID") + os_log(.error, log: .subscription, "[StorePurchaseManager] Error: Failed to create UUID") return .failure(PurchaseManagerError.externalIDisNotAValidUUID) } @@ -190,11 +202,11 @@ public final class PurchaseManager: ObservableObject { do { purchaseResult = try await product.purchase(options: options) } catch { - os_log(.error, log: .subscription, "[PurchaseManager] Error: %{public}s", String(reflecting: error)) + os_log(.error, log: .subscription, "[StorePurchaseManager] Error: %{public}s", String(reflecting: error)) return .failure(PurchaseManagerError.purchaseFailed) } - os_log(.info, log: .subscription, "[PurchaseManager] purchaseSubscription complete") + os_log(.info, log: .subscription, "[StorePurchaseManager] purchaseSubscription complete") purchaseQueue.removeAll() @@ -202,27 +214,27 @@ public final class PurchaseManager: ObservableObject { case let .success(verificationResult): switch verificationResult { case let .verified(transaction): - os_log(.info, log: .subscription, "[PurchaseManager] purchaseSubscription result: success") + os_log(.info, log: .subscription, "[StorePurchaseManager] purchaseSubscription result: success") // Successful purchase await transaction.finish() await self.updatePurchasedProducts() return .success(verificationResult.jwsRepresentation) case let .unverified(_, error): - os_log(.info, log: .subscription, "[PurchaseManager] purchaseSubscription result: success /unverified/ - %{public}s", String(reflecting: error)) + os_log(.info, log: .subscription, "[StorePurchaseManager] purchaseSubscription result: success /unverified/ - %{public}s", String(reflecting: error)) // Successful purchase but transaction/receipt can't be verified // Could be a jailbroken phone return .failure(PurchaseManagerError.transactionCannotBeVerified) } case .pending: - os_log(.info, log: .subscription, "[PurchaseManager] purchaseSubscription result: pending") + os_log(.info, log: .subscription, "[StorePurchaseManager] purchaseSubscription result: pending") // Transaction waiting on SCA (Strong Customer Authentication) or // approval from Ask to Buy return .failure(PurchaseManagerError.transactionPendingAuthentication) case .userCancelled: - os_log(.info, log: .subscription, "[PurchaseManager] purchaseSubscription result: user cancelled") + os_log(.info, log: .subscription, "[StorePurchaseManager] purchaseSubscription result: user cancelled") return .failure(PurchaseManagerError.purchaseCancelledByUser) @unknown default: - os_log(.info, log: .subscription, "[PurchaseManager] purchaseSubscription result: unknown") + os_log(.info, log: .subscription, "[StorePurchaseManager] purchaseSubscription result: unknown") return .failure(PurchaseManagerError.unknownError) } } @@ -243,7 +255,7 @@ public final class PurchaseManager: ObservableObject { Task.detached { [unowned self] in for await result in Transaction.updates { - os_log(.info, log: .subscription, "[PurchaseManager] observeTransactionUpdates") + os_log(.info, log: .subscription, "[StorePurchaseManager] observeTransactionUpdates") if case .verified(let transaction) = result { await transaction.finish() @@ -258,7 +270,7 @@ public final class PurchaseManager: ObservableObject { Task.detached { [unowned self] in for await result in Storefront.updates { - os_log(.info, log: .subscription, "[PurchaseManager] observeStorefrontChanges: %s", result.countryCode) + os_log(.info, log: .subscription, "[StorePurchaseManager] observeStorefrontChanges: %s", result.countryCode) await updatePurchasedProducts() await updateAvailableProducts() } diff --git a/Sources/Subscription/SubManagers/StorePurchaseManaging.swift b/Sources/Subscription/SubManagers/StorePurchaseManaging.swift new file mode 100644 index 000000000..c4ac847cb --- /dev/null +++ b/Sources/Subscription/SubManagers/StorePurchaseManaging.swift @@ -0,0 +1,58 @@ +// +// StorePurchaseManaging.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 Foundation + +public enum StoreError: Error { + case failedVerification +} + +public enum PurchaseManagerError: Error { + case productNotFound + case externalIDisNotAValidUUID + case purchaseFailed + case transactionCannotBeVerified + case transactionPendingAuthentication + case purchaseCancelledByUser + case unknownError +} + +public protocol StorePurchaseManaging { + + func subscriptionOptions() async -> SubscriptionOptions? + + var purchasedProductIDs: [String] { get } + + var purchaseQueue: [String] { get } + + var areProductsAvailable: Bool { get } + + @discardableResult @MainActor func syncAppleIDAccount() async -> Result + + @MainActor func updateAvailableProducts() async + + @MainActor func updatePurchasedProducts() async + + @MainActor func mostRecentTransaction() async -> String? + + @MainActor func hasActiveSubscription() async -> Bool + + typealias TransactionJWS = String + + @MainActor func purchaseSubscription(with identifier: String, externalID: String) async -> Result +} diff --git a/Sources/Subscription/SubscriptionEnvironment.swift b/Sources/Subscription/SubscriptionEnvironment.swift new file mode 100644 index 000000000..3f5ed3bf2 --- /dev/null +++ b/Sources/Subscription/SubscriptionEnvironment.swift @@ -0,0 +1,45 @@ +// +// SubscriptionEnvironment.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 Foundation + +public struct SubscriptionEnvironment: Codable { + + public enum ServiceEnvironment: Codable { + case production, staging + + public var description: String { + switch self { + case .production: return "Production" + case .staging: return "Staging" + } + } + } + + public enum PurchasePlatform: String, Codable { + case appStore, stripe + } + + public var serviceEnvironment: SubscriptionEnvironment.ServiceEnvironment + public var purchasePlatform: SubscriptionEnvironment.PurchasePlatform + + public init(serviceEnvironment: SubscriptionEnvironment.ServiceEnvironment, purchasePlatform: SubscriptionEnvironment.PurchasePlatform) { + self.serviceEnvironment = serviceEnvironment + self.purchasePlatform = purchasePlatform + } +} diff --git a/Sources/Subscription/SubscriptionManager.swift b/Sources/Subscription/SubscriptionManager.swift new file mode 100644 index 000000000..96d1806b9 --- /dev/null +++ b/Sources/Subscription/SubscriptionManager.swift @@ -0,0 +1,138 @@ +// +// SubscriptionManager.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 Foundation +import Common + +public protocol SubscriptionManaging { + + var accountManager: AccountManaging { get } + var subscriptionService: SubscriptionService { get } + var authService: AuthService { get } + var currentEnvironment: SubscriptionEnvironment { get } + var canPurchase: Bool { get } + @available(macOS 12.0, iOS 15.0, *) func storePurchaseManager() -> StorePurchaseManaging + func loadInitialData() + func updateSubscriptionStatus(completion: @escaping (_ isActive: Bool) -> Void) + func url(for type: SubscriptionURL) -> URL +} + +/// Single entry point for everything related to Subscription. This manager is disposable, every time something related to the environment changes this need to be recreated. +final public class SubscriptionManager: SubscriptionManaging { + + private let _storePurchaseManager: StorePurchaseManaging? + + public let accountManager: AccountManaging + public let subscriptionService: SubscriptionService + public let authService: AuthService + public let currentEnvironment: SubscriptionEnvironment + public private(set) var canPurchase: Bool = false + + public init(storePurchaseManager: StorePurchaseManaging? = nil, + accountManager: AccountManaging, + subscriptionService: SubscriptionService, + authService: AuthService, + subscriptionEnvironment: SubscriptionEnvironment) { + self._storePurchaseManager = storePurchaseManager + self.accountManager = accountManager + self.subscriptionService = subscriptionService + self.authService = authService + self.currentEnvironment = subscriptionEnvironment + switch currentEnvironment.purchasePlatform { + case .appStore: + if #available(macOS 12.0, iOS 15.0, *) { + setupForAppStore() + } else { + assertionFailure("Trying to setup AppStore where not supported") + } + case .stripe: + setupForStripe() + } + } + + @available(macOS 12.0, iOS 15.0, *) + public func storePurchaseManager() -> StorePurchaseManaging { + return _storePurchaseManager! + } + + // MARK: Load and Save SubscriptionEnvironment + + static private let subscriptionEnvironmentStorageKey = "com.duckduckgo.subscription.environment" + static public func loadEnvironmentFrom(userDefaults: UserDefaults) -> SubscriptionEnvironment? { + if let savedData = userDefaults.object(forKey: Self.subscriptionEnvironmentStorageKey) as? Data { + let decoder = JSONDecoder() + if let loadedData = try? decoder.decode(SubscriptionEnvironment.self, from: savedData) { + return loadedData + } + } + return nil + } + + static public func save(subscriptionEnvironment: SubscriptionEnvironment, userDefaults: UserDefaults) { + let encoder = JSONEncoder() + if let encodedData = try? encoder.encode(subscriptionEnvironment) { + userDefaults.set(encodedData, forKey: Self.subscriptionEnvironmentStorageKey) + } + } + + // MARK: - Environment, ex SubscriptionPurchaseEnvironment + + @available(macOS 12.0, iOS 15.0, *) private func setupForAppStore() { + Task { + await storePurchaseManager().updateAvailableProducts() + canPurchase = storePurchaseManager().areProductsAvailable + } + } + + private func setupForStripe() { + Task { + if case let .success(products) = await subscriptionService.getProducts() { + canPurchase = !products.isEmpty + } + } + } + + // MARK: - + + public func loadInitialData() { + Task { + if let token = accountManager.accessToken { + _ = await subscriptionService.getSubscription(accessToken: token, cachePolicy: .reloadIgnoringLocalCacheData) + _ = await accountManager.fetchEntitlements(cachePolicy: .reloadIgnoringLocalCacheData) + } + } + } + + public func updateSubscriptionStatus(completion: @escaping (_ isActive: Bool) -> Void) { + Task { + guard let token = accountManager.accessToken else { return } + + if case .success(let subscription) = await subscriptionService.getSubscription(accessToken: token, cachePolicy: .reloadIgnoringLocalCacheData) { + completion(subscription.isActive) + } + + _ = await accountManager.fetchEntitlements(cachePolicy: .reloadIgnoringLocalCacheData) + } + } + + // MARK: - URLs + + public func url(for type: SubscriptionURL) -> URL { + type.subscriptionURL(environment: currentEnvironment.serviceEnvironment) + } +} diff --git a/Sources/Subscription/SubscriptionPurchaseEnvironment.swift b/Sources/Subscription/SubscriptionPurchaseEnvironment.swift deleted file mode 100644 index 4419da4a6..000000000 --- a/Sources/Subscription/SubscriptionPurchaseEnvironment.swift +++ /dev/null @@ -1,81 +0,0 @@ -// -// SubscriptionPurchaseEnvironment.swift -// -// Copyright © 2023 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 Foundation -import Common - -public final class SubscriptionPurchaseEnvironment { - - public enum ServiceEnvironment: String, Codable { - case production - case staging - - public static var `default`: ServiceEnvironment = .production - - public var description: String { - switch self { - case .production: return "Production" - case .staging: return "Staging" - } - } - } - - public static var currentServiceEnvironment: ServiceEnvironment = .default - - public enum Environment: String { - case appStore, stripe - } - - public static var current: Environment = .appStore { - didSet { - os_log(.info, log: .subscription, "[SubscriptionPurchaseEnvironment] Setting to %{public}s", current.rawValue) - - canPurchase = false - - switch current { - case .appStore: - setupForAppStore() - case .stripe: - setupForStripe() - } - } - } - - public static var canPurchase: Bool = false { - didSet { - os_log(.info, log: .subscription, "[SubscriptionPurchaseEnvironment] canPurchase %{public}s", (canPurchase ? "true" : "false")) - } - } - - private static func setupForAppStore() { - if #available(macOS 12.0, iOS 15.0, *) { - Task { - await PurchaseManager.shared.updateAvailableProducts() - canPurchase = !PurchaseManager.shared.availableProducts.isEmpty - } - } - } - - private static func setupForStripe() { - Task { - if case let .success(products) = await SubscriptionService.getProducts() { - canPurchase = !products.isEmpty - } - } - } -} diff --git a/Sources/Subscription/SubscriptionURL.swift b/Sources/Subscription/SubscriptionURL.swift new file mode 100644 index 000000000..6c99c151f --- /dev/null +++ b/Sources/Subscription/SubscriptionURL.swift @@ -0,0 +1,92 @@ +// +// SubscriptionURL.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 Foundation + +// MARK: - URLs, ex URL+Subscription + +public enum SubscriptionURL { + case baseURL + case purchase + case faq + case activateViaEmail + case addEmail + case manageEmail + case activateSuccess + case addEmailToSubscriptionSuccess + case addEmailToSubscriptionOTP + case manageSubscriptionsInAppStore + case identityTheftRestoration + + // swiftlint:disable:next cyclomatic_complexity + func subscriptionURL(environment: SubscriptionEnvironment.ServiceEnvironment) -> URL { + switch self { + case .baseURL: + switch environment { + case .production: + URL(string: "https://duckduckgo.com/subscriptions")! + case .staging: + URL(string: "https://duckduckgo.com/subscriptions?environment=staging")! + } + case .purchase: + SubscriptionURL.baseURL.subscriptionURL(environment: environment).appendingPathComponent("welcome") + case .faq: + URL(string: "https://duckduckgo.com/duckduckgo-help-pages/privacy-pro/")! + case .activateViaEmail: + SubscriptionURL.baseURL.subscriptionURL(environment: environment).appendingPathComponent("activate") + case .addEmail: + SubscriptionURL.baseURL.subscriptionURL(environment: environment).appendingPathComponent("add-email") + case .manageEmail: + SubscriptionURL.baseURL.subscriptionURL(environment: environment).appendingPathComponent("manage") + case .activateSuccess: + SubscriptionURL.baseURL.subscriptionURL(environment: environment).appendingPathComponent("activate/success") + case .addEmailToSubscriptionSuccess: + SubscriptionURL.baseURL.subscriptionURL(environment: environment).appendingPathComponent("add-email/success") + case .addEmailToSubscriptionOTP: + SubscriptionURL.baseURL.subscriptionURL(environment: environment).appendingPathComponent("add-email/otp") + case .manageSubscriptionsInAppStore: + URL(string: "macappstores://apps.apple.com/account/subscriptions")! + case .identityTheftRestoration: + switch environment { + case .production: + URL(string: "https://duckduckgo.com/identity-theft-restoration")! + case .staging: + URL(string: "https://duckduckgo.com/identity-theft-restoration?environment=staging")! + } + } + } +} + +extension URL { + + public func forComparison() -> URL { + guard var components = URLComponents(url: self, resolvingAgainstBaseURL: false) else { + return self + } + + if let queryItems = components.queryItems, !queryItems.isEmpty { + components.queryItems = queryItems.filter { !["environment", "origin"].contains($0.name) } + if components.queryItems?.isEmpty ?? true { + components.queryItems = nil + } + } else { + components.queryItems = nil + } + return components.url ?? self + } +} diff --git a/Sources/Subscription/URL+Subscription.swift b/Sources/Subscription/URL+Subscription.swift deleted file mode 100644 index f78493719..000000000 --- a/Sources/Subscription/URL+Subscription.swift +++ /dev/null @@ -1,101 +0,0 @@ -// -// URL+Subscription.swift -// -// Copyright © 2023 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 Foundation - -public extension URL { - - static var subscriptionBaseURL: URL { - switch SubscriptionPurchaseEnvironment.currentServiceEnvironment { - case .production: - URL(string: "https://duckduckgo.com/subscriptions")! - case .staging: - URL(string: "https://duckduckgo.com/subscriptions?environment=staging")! - } - } - - static var subscriptionPurchase: URL { - subscriptionBaseURL.appendingPathComponent("welcome") - } - - static var subscriptionFAQ: URL { - URL(string: "https://duckduckgo.com/duckduckgo-help-pages/privacy-pro/")! - } - - // MARK: - Subscription Email - static var activateSubscriptionViaEmail: URL { - subscriptionBaseURL.appendingPathComponent("activate") - } - - static var addEmailToSubscription: URL { - subscriptionBaseURL.appendingPathComponent("add-email") - } - - static var manageSubscriptionEmail: URL { - subscriptionBaseURL.appendingPathComponent("manage") - } - - static var subscriptionActivateSuccess: URL { - subscriptionBaseURL.appendingPathComponent("activate/success") - } - - // Add Email Success - static var addEmailToSubscriptionSuccess: URL { - subscriptionBaseURL.appendingPathComponent("add-email/success") - } - - // Add Email OTP - static var addEmailToSubscriptionOTP: URL { - subscriptionBaseURL.appendingPathComponent("add-email/otp") - } - - // MARK: - App Store app manage subscription URL - - static var manageSubscriptionsInAppStoreAppURL: URL { - URL(string: "macappstores://apps.apple.com/account/subscriptions")! - } - - // MARK: - Identity Theft Restoration - - static var identityTheftRestoration: URL { - switch SubscriptionPurchaseEnvironment.currentServiceEnvironment { - case .production: - URL(string: "https://duckduckgo.com/identity-theft-restoration")! - case .staging: - URL(string: "https://duckduckgo.com/identity-theft-restoration?environment=staging")! - } - } - - func forComparison() -> URL { - guard var components = URLComponents(url: self, resolvingAgainstBaseURL: false) else { - return self - } - - if let queryItems = components.queryItems, !queryItems.isEmpty { - components.queryItems = queryItems.filter { !["environment", "origin"].contains($0.name) } - - if components.queryItems?.isEmpty ?? true { - components.queryItems = nil - } - } else { - components.queryItems = nil - } - return components.url ?? self - } - -} diff --git a/Sources/SubscriptionTestingUtilities/AccountManagerMock.swift b/Sources/SubscriptionTestingUtilities/AccountManagerMock.swift new file mode 100644 index 000000000..070f827b5 --- /dev/null +++ b/Sources/SubscriptionTestingUtilities/AccountManagerMock.swift @@ -0,0 +1,101 @@ +// +// AccountManagerMock.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 Foundation +import Subscription + +public final class AccountManagerMock: AccountManaging { + + public var delegate: AccountManagerKeychainAccessDelegate? + public var isUserAuthenticated: Bool + public var accessToken: String? + public var authToken: String? + public var email: String? + public var externalID: String? + + public init(delegate: AccountManagerKeychainAccessDelegate? = nil, + isUserAuthenticated: Bool, + accessToken: String? = nil, + authToken: String? = nil, + email: String? = nil, + externalID: String? = nil) { + self.delegate = delegate + self.isUserAuthenticated = isUserAuthenticated + self.accessToken = accessToken + self.authToken = authToken + self.email = email + self.externalID = externalID + } + + public func storeAuthToken(token: String) { + authToken = token + } + + public func storeAccount(token: String, email: String?, externalID: String?) { + accessToken = token + } + + public func signOut(skipNotification: Bool) { + accessToken = nil + } + + public func signOut() { + accessToken = nil + } + + public func migrateAccessTokenToNewStore() throws { + + } + + public func hasEntitlement(for entitlement: Entitlement.ProductName, cachePolicy: CachePolicy) async -> Result { + return .success(true) + } + + public func hasEntitlement(for entitlement: Entitlement.ProductName) async -> Result { + return .success(true) + } + + public func updateCache(with entitlements: [Entitlement]) { + + } + + public func fetchEntitlements(cachePolicy: CachePolicy) async -> Result<[Entitlement], Error> { + return .success([]) + } + + public func exchangeAuthTokenToAccessToken(_ authToken: String) async -> Result { + return .success("") + } + + public func fetchAccountDetails(with accessToken: String) async -> Result { + if let email, let externalID { + let details: AccountDetails = (email: email, externalID: externalID) + return .success(details) + } else { + return .failure(APIServiceError.unknownServerError) + } + } + + public func refreshSubscriptionAndEntitlements() async { + + } + + public func checkForEntitlements(wait waitTime: Double, retry retryCount: Int) async -> Bool { + return true + } +} diff --git a/Sources/SubscriptionTestingUtilities/SubscriptionManagerMock.swift b/Sources/SubscriptionTestingUtilities/SubscriptionManagerMock.swift new file mode 100644 index 000000000..ca2566eaf --- /dev/null +++ b/Sources/SubscriptionTestingUtilities/SubscriptionManagerMock.swift @@ -0,0 +1,63 @@ +// +// SubscriptionManagerMock.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 Foundation +@testable import Subscription + +public final class SubscriptionManagerMock: SubscriptionManaging { + + public var accountManager: AccountManaging + public var subscriptionService: SubscriptionService + public var authService: AuthService + public var currentEnvironment: SubscriptionEnvironment + public var canPurchase: Bool + + public func storePurchaseManager() -> StorePurchaseManaging { + internalStorePurchaseManager + } + + public func loadInitialData() { + + } + + public func updateSubscriptionStatus(completion: @escaping (Bool) -> Void) { + completion(true) + } + + public func url(for type: SubscriptionURL) -> URL { + type.subscriptionURL(environment: currentEnvironment.serviceEnvironment) + } + + public init(accountManager: AccountManaging, + subscriptionService: SubscriptionService, + authService: AuthService, + storePurchaseManager: StorePurchaseManaging, + currentEnvironment: SubscriptionEnvironment, + canPurchase: Bool) { + self.accountManager = accountManager + self.subscriptionService = subscriptionService + self.authService = authService + self.internalStorePurchaseManager = storePurchaseManager + self.currentEnvironment = currentEnvironment + self.canPurchase = canPurchase + } + + // MARK: - + + let internalStorePurchaseManager: StorePurchaseManaging +} diff --git a/Tests/BrowserServicesKitTests/Subscription/SubscriptionFeatureAvailabilityTests.swift b/Tests/BrowserServicesKitTests/Subscription/SubscriptionFeatureAvailabilityTests.swift index ca16372ea..ed65ea76e 100644 --- a/Tests/BrowserServicesKitTests/Subscription/SubscriptionFeatureAvailabilityTests.swift +++ b/Tests/BrowserServicesKitTests/Subscription/SubscriptionFeatureAvailabilityTests.swift @@ -48,8 +48,6 @@ final class SubscriptionFeatureAvailabilityTests: XCTestCase { // MARK: - Tests for App Store func testSubscriptionFeatureNotAvailableWhenAllFlagsDisabledAndNotInternalUser() { - let purchasePlatform = SubscriptionPurchaseEnvironment.Environment.appStore - internalUserDeciderStore.isInternalUser = false XCTAssertFalse(internalUserDeciderStore.isInternalUser) @@ -58,14 +56,12 @@ final class SubscriptionFeatureAvailabilityTests: XCTestCase { XCTAssertFalse(privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.allowPurchase)) let subscriptionFeatureAvailability = DefaultSubscriptionFeatureAvailability(privacyConfigurationManager: privacyConfigurationManager, - purchasePlatform: purchasePlatform) + purchasePlatform: .appStore) XCTAssertFalse(subscriptionFeatureAvailability.isFeatureAvailable) XCTAssertFalse(subscriptionFeatureAvailability.isSubscriptionPurchaseAllowed) } func testSubscriptionFeatureAvailableWhenIsLaunchedFlagEnabled() { - let purchasePlatform = SubscriptionPurchaseEnvironment.Environment.appStore - internalUserDeciderStore.isInternalUser = false XCTAssertFalse(internalUserDeciderStore.isInternalUser) @@ -76,14 +72,12 @@ final class SubscriptionFeatureAvailabilityTests: XCTestCase { XCTAssertTrue(privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.allowPurchase)) let subscriptionFeatureAvailability = DefaultSubscriptionFeatureAvailability(privacyConfigurationManager: privacyConfigurationManager, - purchasePlatform: purchasePlatform) + purchasePlatform: .appStore) XCTAssertTrue(subscriptionFeatureAvailability.isFeatureAvailable) XCTAssertTrue(subscriptionFeatureAvailability.isSubscriptionPurchaseAllowed) } func testSubscriptionFeatureAvailableWhenIsLaunchedOverrideFlagEnabled() { - let purchasePlatform = SubscriptionPurchaseEnvironment.Environment.appStore - internalUserDeciderStore.isInternalUser = false XCTAssertFalse(internalUserDeciderStore.isInternalUser) @@ -94,14 +88,12 @@ final class SubscriptionFeatureAvailabilityTests: XCTestCase { XCTAssertTrue(privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.allowPurchase)) let subscriptionFeatureAvailability = DefaultSubscriptionFeatureAvailability(privacyConfigurationManager: privacyConfigurationManager, - purchasePlatform: purchasePlatform) + purchasePlatform: .appStore) XCTAssertTrue(subscriptionFeatureAvailability.isFeatureAvailable) XCTAssertTrue(subscriptionFeatureAvailability.isSubscriptionPurchaseAllowed) } func testSubscriptionFeatureAvailableAndPurchaseNotAllowed() { - let purchasePlatform = SubscriptionPurchaseEnvironment.Environment.appStore - internalUserDeciderStore.isInternalUser = false XCTAssertFalse(internalUserDeciderStore.isInternalUser) @@ -112,14 +104,12 @@ final class SubscriptionFeatureAvailabilityTests: XCTestCase { XCTAssertFalse(privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.allowPurchase)) let subscriptionFeatureAvailability = DefaultSubscriptionFeatureAvailability(privacyConfigurationManager: privacyConfigurationManager, - purchasePlatform: purchasePlatform) + purchasePlatform: .appStore) XCTAssertTrue(subscriptionFeatureAvailability.isFeatureAvailable) XCTAssertFalse(subscriptionFeatureAvailability.isSubscriptionPurchaseAllowed) } func testSubscriptionFeatureAvailableWhenAllFlagsDisabledAndInternalUser() { - let purchasePlatform = SubscriptionPurchaseEnvironment.Environment.appStore - internalUserDeciderStore.isInternalUser = true XCTAssertTrue(internalUserDeciderStore.isInternalUser) @@ -128,7 +118,7 @@ final class SubscriptionFeatureAvailabilityTests: XCTestCase { XCTAssertFalse(privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.allowPurchase)) let subscriptionFeatureAvailability = DefaultSubscriptionFeatureAvailability(privacyConfigurationManager: privacyConfigurationManager, - purchasePlatform: purchasePlatform) + purchasePlatform: .appStore) XCTAssertTrue(subscriptionFeatureAvailability.isFeatureAvailable) XCTAssertTrue(subscriptionFeatureAvailability.isSubscriptionPurchaseAllowed) } @@ -136,8 +126,6 @@ final class SubscriptionFeatureAvailabilityTests: XCTestCase { // MARK: - Tests for Stripe func testStripeSubscriptionFeatureNotAvailableWhenAllFlagsDisabledAndNotInternalUser() { - let purchasePlatform = SubscriptionPurchaseEnvironment.Environment.stripe - internalUserDeciderStore.isInternalUser = false XCTAssertFalse(internalUserDeciderStore.isInternalUser) @@ -146,14 +134,12 @@ final class SubscriptionFeatureAvailabilityTests: XCTestCase { XCTAssertFalse(privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.allowPurchaseStripe)) let subscriptionFeatureAvailability = DefaultSubscriptionFeatureAvailability(privacyConfigurationManager: privacyConfigurationManager, - purchasePlatform: purchasePlatform) + purchasePlatform: .stripe) XCTAssertFalse(subscriptionFeatureAvailability.isFeatureAvailable) XCTAssertFalse(subscriptionFeatureAvailability.isSubscriptionPurchaseAllowed) } func testStripeSubscriptionFeatureAvailableWhenIsLaunchedFlagEnabled() { - let purchasePlatform = SubscriptionPurchaseEnvironment.Environment.stripe - internalUserDeciderStore.isInternalUser = false XCTAssertFalse(internalUserDeciderStore.isInternalUser) @@ -164,14 +150,12 @@ final class SubscriptionFeatureAvailabilityTests: XCTestCase { XCTAssertTrue(privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.allowPurchaseStripe)) let subscriptionFeatureAvailability = DefaultSubscriptionFeatureAvailability(privacyConfigurationManager: privacyConfigurationManager, - purchasePlatform: purchasePlatform) + purchasePlatform: .stripe) XCTAssertTrue(subscriptionFeatureAvailability.isFeatureAvailable) XCTAssertTrue(subscriptionFeatureAvailability.isSubscriptionPurchaseAllowed) } func testStripeSubscriptionFeatureAvailableWhenIsLaunchedOverrideFlagEnabled() { - let purchasePlatform = SubscriptionPurchaseEnvironment.Environment.stripe - internalUserDeciderStore.isInternalUser = false XCTAssertFalse(internalUserDeciderStore.isInternalUser) @@ -182,14 +166,12 @@ final class SubscriptionFeatureAvailabilityTests: XCTestCase { XCTAssertTrue(privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.allowPurchaseStripe)) let subscriptionFeatureAvailability = DefaultSubscriptionFeatureAvailability(privacyConfigurationManager: privacyConfigurationManager, - purchasePlatform: purchasePlatform) + purchasePlatform: .stripe) XCTAssertTrue(subscriptionFeatureAvailability.isFeatureAvailable) XCTAssertTrue(subscriptionFeatureAvailability.isSubscriptionPurchaseAllowed) } func testStripeSubscriptionFeatureAvailableAndPurchaseNotAllowed() { - let purchasePlatform = SubscriptionPurchaseEnvironment.Environment.stripe - internalUserDeciderStore.isInternalUser = false XCTAssertFalse(internalUserDeciderStore.isInternalUser) @@ -200,14 +182,12 @@ final class SubscriptionFeatureAvailabilityTests: XCTestCase { XCTAssertFalse(privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.allowPurchaseStripe)) let subscriptionFeatureAvailability = DefaultSubscriptionFeatureAvailability(privacyConfigurationManager: privacyConfigurationManager, - purchasePlatform: purchasePlatform) + purchasePlatform: .stripe) XCTAssertTrue(subscriptionFeatureAvailability.isFeatureAvailable) XCTAssertFalse(subscriptionFeatureAvailability.isSubscriptionPurchaseAllowed) } func testStripeSubscriptionFeatureAvailableWhenAllFlagsDisabledAndInternalUser() { - let purchasePlatform = SubscriptionPurchaseEnvironment.Environment.stripe - internalUserDeciderStore.isInternalUser = true XCTAssertTrue(internalUserDeciderStore.isInternalUser) @@ -216,7 +196,7 @@ final class SubscriptionFeatureAvailabilityTests: XCTestCase { XCTAssertFalse(privacyConfig.isSubfeatureEnabled(PrivacyProSubfeature.allowPurchaseStripe)) let subscriptionFeatureAvailability = DefaultSubscriptionFeatureAvailability(privacyConfigurationManager: privacyConfigurationManager, - purchasePlatform: purchasePlatform) + purchasePlatform: .stripe) XCTAssertTrue(subscriptionFeatureAvailability.isFeatureAvailable) XCTAssertTrue(subscriptionFeatureAvailability.isSubscriptionPurchaseAllowed) } diff --git a/Tests/SubscriptionTests/SubscriptionTests.swift b/Tests/SubscriptionTests/SubscriptionTests.swift new file mode 100644 index 000000000..6225b8df6 --- /dev/null +++ b/Tests/SubscriptionTests/SubscriptionTests.swift @@ -0,0 +1,25 @@ +// +// SubscriptionTests.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 +@testable import Subscription +import SubscriptionTestingUtilities + +final class SubscriptionTests: XCTestCase { + +} From d18f97300d105ec5b8fb5fbe54659c661dea6158 Mon Sep 17 00:00:00 2001 From: Christopher Brind Date: Thu, 23 May 2024 15:33:36 +0100 Subject: [PATCH 2/7] ensure bookmarks can be shown in top hits (#818) * ensure bookmarks can be shown in top hits * call clean with todays date instead trying to use entries which might not have loaded yet * see if unit test line number reported changes * wait for burn all to finish before asserting --- Sources/History/HistoryCoordinator.swift | 9 +++------ Sources/Suggestions/SuggestionProcessing.swift | 14 ++++++++++++++ Tests/HistoryTests/HistoryCoordinatorTests.swift | 11 ++++++++++- 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/Sources/History/HistoryCoordinator.swift b/Sources/History/HistoryCoordinator.swift index d9fb7f8c6..54d2df181 100644 --- a/Sources/History/HistoryCoordinator.swift +++ b/Sources/History/HistoryCoordinator.swift @@ -161,13 +161,10 @@ final public class HistoryCoordinator: HistoryCoordinating { } public func burnAll(completion: @escaping () -> Void) { - guard let historyDictionary = historyDictionary else { return } - - let entries = Array(historyDictionary.values) - - removeEntries(entries, completionHandler: { _ in + clean(until: Date()) { + self.historyDictionary = [:] completion() - }) + } } public func burnDomains(_ baseDomains: Set, tld: TLD, completion: @escaping () -> Void) { diff --git a/Sources/Suggestions/SuggestionProcessing.swift b/Sources/Suggestions/SuggestionProcessing.swift index 27dfcab63..b80dbf20f 100644 --- a/Sources/Suggestions/SuggestionProcessing.swift +++ b/Sources/Suggestions/SuggestionProcessing.swift @@ -157,11 +157,18 @@ final class SuggestionProcessing { if case .bookmark = $0, $0.url?.naked == nakedUrl { return true } return false }), case let Suggestion.bookmark(title: title, url: url, isFavorite: isFavorite, allowedInTopHits: _) = newSuggestion { + #if os(macOS) // Copy allowedInTopHits from original suggestion return Suggestion.bookmark(title: title, url: url, isFavorite: isFavorite, allowedInTopHits: historySuggestion.allowedInTopHits) + #else + return Suggestion.bookmark(title: title, + url: url, + isFavorite: isFavorite, + allowedInTopHits: true) + #endif } else { return nil } @@ -178,10 +185,17 @@ final class SuggestionProcessing { if case .historyEntry = $0, $0.url?.naked == nakedUrl { return true } return false }), historySuggestion.allowedInTopHits { + #if os(macOS) return Suggestion.bookmark(title: title, url: url, isFavorite: isFavorite, allowedInTopHits: historySuggestion.allowedInTopHits) + #else + return Suggestion.bookmark(title: title, + url: url, + isFavorite: isFavorite, + allowedInTopHits: true) + #endif } else { return nil } diff --git a/Tests/HistoryTests/HistoryCoordinatorTests.swift b/Tests/HistoryTests/HistoryCoordinatorTests.swift index 6358347b8..968f203e9 100644 --- a/Tests/HistoryTests/HistoryCoordinatorTests.swift +++ b/Tests/HistoryTests/HistoryCoordinatorTests.swift @@ -116,6 +116,7 @@ class HistoryCoordinatorTests: XCTestCase { } func testWhenHistoryIsBurning_ThenHistoryIsCleanedIncludingFireproofDomains() { + let burnAllFinished = expectation(description: "Burn All Finished") let (historyStoringMock, historyCoordinator) = HistoryCoordinator.aHistoryCoordinator let url1 = URL(string: "https://duckduckgo.com")! @@ -134,8 +135,16 @@ class HistoryCoordinatorTests: XCTestCase { XCTAssert(historyCoordinator.history!.count == 4) historyCoordinator.burnAll { - XCTAssert(historyStoringMock.removeEntriesArray.count == 4) + // We now clean the database directly so we don't burn by entry + XCTAssert(historyStoringMock.removeEntriesArray.count == 0) + + // And we reset the entries dictionary + XCTAssert(historyCoordinator.history!.count == 0) + + burnAllFinished.fulfill() } + + waitForExpectations(timeout: 2.0) } func testWhenBurningVisits_removesHistoryWhenVisitsCountHitsZero() { From 65f3ccadd0118bcef112e3f5fff03e491b3261cd Mon Sep 17 00:00:00 2001 From: Brian Hall Date: Thu, 23 May 2024 10:04:46 -0500 Subject: [PATCH 3/7] Bump C-S-S to 5.17.0 (#828) --- Package.resolved | 4 ++-- Package.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Package.resolved b/Package.resolved index b184b9292..ca4d76c93 100644 --- a/Package.resolved +++ b/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/duckduckgo/content-scope-scripts", "state" : { - "revision" : "bb8e7e62104ed6506c7bfd3ef7aa4aca3686ed4f", - "version" : "5.15.0" + "revision" : "fa861c4eccb21d235e34070b208b78bdc32ece08", + "version" : "5.17.0" } }, { diff --git a/Package.swift b/Package.swift index 6a67da598..dc51ed8ad 100644 --- a/Package.swift +++ b/Package.swift @@ -44,7 +44,7 @@ let package = Package( .package(url: "https://github.com/duckduckgo/TrackerRadarKit", exact: "2.1.1"), .package(url: "https://github.com/duckduckgo/sync_crypto", exact: "0.2.0"), .package(url: "https://github.com/gumob/PunycodeSwift.git", exact: "2.1.0"), - .package(url: "https://github.com/duckduckgo/content-scope-scripts", exact: "5.15.0"), + .package(url: "https://github.com/duckduckgo/content-scope-scripts", exact: "5.17.0"), .package(url: "https://github.com/duckduckgo/privacy-dashboard", exact: "3.6.0"), .package(url: "https://github.com/httpswift/swifter.git", exact: "1.5.0"), .package(url: "https://github.com/duckduckgo/bloom_cpp.git", exact: "3.0.0"), From e1e436422bc167933baa0f90838958f2ac7119f3 Mon Sep 17 00:00:00 2001 From: amddg44 Date: Thu, 23 May 2024 21:02:46 +0200 Subject: [PATCH 4/7] Autofill engagement KPIs for pixel reporting (#830) Task/Issue URL: https://app.asana.com/0/72649045549333/1207357107981852/f iOS PR: duckduckgo/iOS#2885 macOS PR: duckduckgo/macos-browser#2806 What kind of version bump will this require?: Minor Description: Autofill engagement KPIs for pixel reporting --- .../Autofill/AutofillPixelReporter.swift | 243 ++++++++++ .../AutofillUserScript+SecureVault.swift | 11 + .../AutofillDatabaseProvider.swift | 8 + .../SecureVault/AutofillSecureVault.swift | 16 +- .../SecureVault/SecureVaultManager.swift | 2 + Sources/Common/Extensions/DateExtension.swift | 5 + .../Credentials/CredentialsProvider.swift | 5 + .../Autofill/AutofillPixelReporterTests.swift | 421 ++++++++++++++++++ .../MockAutofillDatabaseProvider.swift | 4 + 9 files changed, 707 insertions(+), 8 deletions(-) create mode 100644 Sources/BrowserServicesKit/Autofill/AutofillPixelReporter.swift create mode 100644 Tests/BrowserServicesKitTests/Autofill/AutofillPixelReporterTests.swift diff --git a/Sources/BrowserServicesKit/Autofill/AutofillPixelReporter.swift b/Sources/BrowserServicesKit/Autofill/AutofillPixelReporter.swift new file mode 100644 index 000000000..b4ed71d70 --- /dev/null +++ b/Sources/BrowserServicesKit/Autofill/AutofillPixelReporter.swift @@ -0,0 +1,243 @@ +// +// AutofillPixelReporter.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 Foundation +import Persistence +import SecureStorage +import Common + +public enum AutofillPixelEvent { + case autofillActiveUser + case autofillEnabledUser + case autofillOnboardedUser + case autofillLoginsStacked + case autofillCreditCardsStacked + + enum Parameter { + static let countBucket = "count_bucket" + } +} + +public final class AutofillPixelReporter { + + enum Keys { + static let autofillSearchDauDateKey = "com.duckduckgo.app.autofill.SearchDauDate" + static let autofillFillDateKey = "com.duckduckgo.app.autofill.FillDate" + static let autofillOnboardedUserKey = "com.duckduckgo.app.autofill.OnboardedUser" + } + + enum BucketName: String { + case none + case few + case some + case many + case lots + } + + private enum EventType { + case fill + case searchDAU + } + + private let userDefaults: UserDefaults + private let eventMapping: EventMapping + private var secureVault: (any AutofillSecureVault)? + private var reporter: SecureVaultReporting? + // Third party password manager + private let passwordManager: PasswordManager? + private var installDate: Date? + + private var autofillSearchDauDate: Date? { userDefaults.object(forKey: Keys.autofillSearchDauDateKey) as? Date ?? .distantPast } + private var autofillFillDate: Date? { userDefaults.object(forKey: Keys.autofillFillDateKey) as? Date ?? .distantPast } + private var autofillOnboardedUser: Bool { userDefaults.object(forKey: Keys.autofillOnboardedUserKey) as? Bool ?? false } + + public init(userDefaults: UserDefaults, + eventMapping: EventMapping, + secureVault: (any AutofillSecureVault)? = nil, + reporter: SecureVaultReporting? = nil, + passwordManager: PasswordManager? = nil, + installDate: Date? = nil + ) { + self.userDefaults = userDefaults + self.eventMapping = eventMapping + self.secureVault = secureVault + self.reporter = reporter + self.passwordManager = passwordManager + self.installDate = installDate + + createNotificationObservers() + } + + public func resetStoreDefaults() { + userDefaults.set(Date.distantPast, forKey: Keys.autofillSearchDauDateKey) + userDefaults.set(Date.distantPast, forKey: Keys.autofillFillDateKey) + userDefaults.set(false, forKey: Keys.autofillOnboardedUserKey) + } + + private func createNotificationObservers() { + NotificationCenter.default.addObserver(self, selector: #selector(didReceiveSearchDAU), name: .searchDAU, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(didReceiveFillEvent), name: .autofillFillEvent, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(didReceiveSaveEvent), name: .autofillSaveEvent, object: nil) + } + + @objc + private func didReceiveSearchDAU() { + guard let autofillSearchDauDate = autofillSearchDauDate, !Date.isSameDay(Date(), autofillSearchDauDate) else { + return + } + + userDefaults.set(Date(), forKey: Keys.autofillSearchDauDateKey) + + firePixelsFor(.searchDAU) + } + + @objc + private func didReceiveFillEvent() { + guard let autofillFillDate = autofillFillDate, !Date.isSameDay(Date(), autofillFillDate) else { + return + } + + userDefaults.set(Date(), forKey: Keys.autofillFillDateKey) + + firePixelsFor(.fill) + } + + @objc + private func didReceiveSaveEvent() { + guard !autofillOnboardedUser else { + return + } + + if shouldFireOnboardedUserPixel() { + eventMapping.fire(.autofillOnboardedUser) + } + } + + private func firePixelsFor(_ type: EventType) { + if shouldFireActiveUserPixel() { + eventMapping.fire(.autofillActiveUser) + + if let accountsCountBucket = getAccountsCountBucket() { + eventMapping.fire(.autofillLoginsStacked, parameters: [AutofillPixelEvent.Parameter.countBucket: accountsCountBucket]) + } + + if let cardsCount = try? vault()?.creditCardsCount() { + eventMapping.fire(.autofillCreditCardsStacked, parameters: [AutofillPixelEvent.Parameter.countBucket: creditCardsBucketNameFrom(count: cardsCount)]) + } + } + + switch type { + case .searchDAU: + if shouldFireEnabledUserPixel() { + eventMapping.fire(.autofillEnabledUser) + } + default: + break + } + } + + private func getAccountsCountBucket() -> String? { + if let passwordManager = passwordManager, passwordManager.isEnabled { + // if a user is using a password manager we can't get a count of their passwords so we are assuming they are likely to have a lot of passwords saved + return BucketName.lots.rawValue + } else if let accountsCount = try? vault()?.accountsCount() { + return accountsBucketNameFrom(count: accountsCount) + } + return nil + } + + private func shouldFireActiveUserPixel() -> Bool { + let today = Date() + if Date.isSameDay(today, autofillSearchDauDate) && Date.isSameDay(today, autofillFillDate) { + return true + } + return false + } + + private func shouldFireEnabledUserPixel() -> Bool { + if Date.isSameDay(Date(), autofillSearchDauDate) { + if let passwordManager = passwordManager, passwordManager.isEnabled { + return true + } else if let count = try? vault()?.accountsCount(), count >= 10 { + return true + } + } + return false + } + + private func shouldFireOnboardedUserPixel() -> Bool { + guard !autofillOnboardedUser, let installDate = installDate else { + return false + } + + let pastWeek = Date().addingTimeInterval(.days(-7)) + + if installDate >= pastWeek { + if let passwordManager = passwordManager, passwordManager.isEnabled { + return true + } else if let count = try? vault()?.accountsCount(), count > 0 { + userDefaults.set(true, forKey: Keys.autofillOnboardedUserKey) + return true + } + } else { + userDefaults.set(true, forKey: Keys.autofillOnboardedUserKey) + } + + return false + } + + private func vault() -> (any AutofillSecureVault)? { + if secureVault == nil { + secureVault = try? AutofillSecureVaultFactory.makeVault(reporter: reporter) + } + return secureVault + } + + private func accountsBucketNameFrom(count: Int) -> String { + if count == 0 { + return BucketName.none.rawValue + } else if count < 4 { + return BucketName.few.rawValue + } else if count < 11 { + return BucketName.some.rawValue + } else if count < 50 { + return BucketName.many.rawValue + } else { + return BucketName.lots.rawValue + } + } + + private func creditCardsBucketNameFrom(count: Int) -> String { + if count == 0 { + return BucketName.none.rawValue + } else if count < 4 { + return BucketName.some.rawValue + } else { + return BucketName.many.rawValue + } + } + +} + +public extension NSNotification.Name { + + static let autofillFillEvent: NSNotification.Name = Notification.Name(rawValue: "com.duckduckgo.browserServicesKit.AutofillFillEvent") + static let autofillSaveEvent: NSNotification.Name = Notification.Name(rawValue: "com.duckduckgo.browserServicesKit.AutofillSaveEvent") + static let searchDAU: NSNotification.Name = Notification.Name(rawValue: "com.duckduckgo.browserServicesKit.SearchDAU") + +} diff --git a/Sources/BrowserServicesKit/Autofill/AutofillUserScript+SecureVault.swift b/Sources/BrowserServicesKit/Autofill/AutofillUserScript+SecureVault.swift index e3a00c4bf..ca5170f5c 100644 --- a/Sources/BrowserServicesKit/Autofill/AutofillUserScript+SecureVault.swift +++ b/Sources/BrowserServicesKit/Autofill/AutofillUserScript+SecureVault.swift @@ -721,6 +721,10 @@ extension AutofillUserScript { case autofillPrivateAddress = "autofill_private_address" } + private enum IdentityPixelName: String { + case autofillIdentity = "autofill_identity" + } + /// The pixel name sent by the JS layer. This name does not include the platform on which it was sent. private let originalPixelName: String @@ -739,6 +743,13 @@ extension AutofillUserScript { } } + public var isIdentityPixel: Bool { + if case IdentityPixelName.autofillIdentity.rawValue = originalPixelName { + return true + } + return false + } + public var pixelName: String { switch originalPixelName { case EmailPixelName.autofillPersonalAddress.rawValue: diff --git a/Sources/BrowserServicesKit/SecureVault/AutofillDatabaseProvider.swift b/Sources/BrowserServicesKit/SecureVault/AutofillDatabaseProvider.swift index 73470a359..70ec30ddc 100644 --- a/Sources/BrowserServicesKit/SecureVault/AutofillDatabaseProvider.swift +++ b/Sources/BrowserServicesKit/SecureVault/AutofillDatabaseProvider.swift @@ -57,6 +57,7 @@ public protocol AutofillDatabaseProvider: SecureStorageDatabaseProvider { func deleteIdentityForIdentityId(_ identityId: Int64) throws func creditCards() throws -> [SecureVaultModels.CreditCard] + func creditCardsCount() throws -> Int func creditCardForCardId(_ cardId: Int64) throws -> SecureVaultModels.CreditCard? @discardableResult func storeCreditCard(_ creditCard: SecureVaultModels.CreditCard) throws -> Int64 @@ -557,6 +558,13 @@ public final class DefaultAutofillDatabaseProvider: GRDBSecureStorageDatabasePro } } + public func creditCardsCount() throws -> Int { + let count = try db.read { + try SecureVaultModels.CreditCard.fetchCount($0) + } + return count + } + public func creditCardForCardId(_ cardId: Int64) throws -> SecureVaultModels.CreditCard? { try db.read { return try SecureVaultModels.CreditCard.fetchOne($0, sql: """ diff --git a/Sources/BrowserServicesKit/SecureVault/AutofillSecureVault.swift b/Sources/BrowserServicesKit/SecureVault/AutofillSecureVault.swift index 498a7a2b7..e411c25b6 100644 --- a/Sources/BrowserServicesKit/SecureVault/AutofillSecureVault.swift +++ b/Sources/BrowserServicesKit/SecureVault/AutofillSecureVault.swift @@ -87,6 +87,7 @@ public protocol AutofillSecureVault: SecureVault { func deleteIdentityFor(identityId: Int64) throws func creditCards() throws -> [SecureVaultModels.CreditCard] + func creditCardsCount() throws -> Int func creditCardFor(id: Int64) throws -> SecureVaultModels.CreditCard? func existingCardForAutofill(matching proposedCard: SecureVaultModels.CreditCard) throws -> SecureVaultModels.CreditCard? @discardableResult @@ -231,15 +232,8 @@ public class DefaultAutofillSecureVault: AutofillSe } public func accountsCount() throws -> Int { - lock.lock() - defer { - lock.unlock() - } - - do { + return try executeThrowingDatabaseOperation { return try self.providers.database.accountsCount() - } catch { - throw SecureStorageError.databaseError(cause: error) } } @@ -526,6 +520,12 @@ public class DefaultAutofillSecureVault: AutofillSe } } + public func creditCardsCount() throws -> Int { + return try executeThrowingDatabaseOperation { + return try self.providers.database.creditCardsCount() + } + } + public func creditCardFor(id: Int64) throws -> SecureVaultModels.CreditCard? { return try executeThrowingDatabaseOperation { guard var card = try self.providers.database.creditCardForCardId(id) else { diff --git a/Sources/BrowserServicesKit/SecureVault/SecureVaultManager.swift b/Sources/BrowserServicesKit/SecureVault/SecureVaultManager.swift index eda3a4569..8fd8f3ebd 100644 --- a/Sources/BrowserServicesKit/SecureVault/SecureVaultManager.swift +++ b/Sources/BrowserServicesKit/SecureVault/SecureVaultManager.swift @@ -246,6 +246,7 @@ extension SecureVaultManager: AutofillSecureVaultDelegate { if autofilldata.trigger == .passwordGeneration { autogeneratedPassword = data.credentials?.autogenerated ?? false + NotificationCenter.default.post(name: .autofillFillEvent, object: nil) } // Account for cases when the user has manually changed an autogenerated password or private email @@ -616,6 +617,7 @@ extension SecureVaultManager: AutofillSecureVaultDelegate { var account = SecureVaultModels.WebsiteAccount(username: username, domain: domain, lastUsed: Date()) let credentials = try? vault?.storeWebsiteCredentials(SecureVaultModels.WebsiteCredentials(account: account, password: password)) account.id = String(credentials ?? -1) + NotificationCenter.default.post(name: .autofillSaveEvent, object: nil, userInfo: nil) return account } diff --git a/Sources/Common/Extensions/DateExtension.swift b/Sources/Common/Extensions/DateExtension.swift index 31ca8730c..7bcba129c 100644 --- a/Sources/Common/Extensions/DateExtension.swift +++ b/Sources/Common/Extensions/DateExtension.swift @@ -41,6 +41,11 @@ public extension Date { return Calendar.current.date(byAdding: .day, value: -days, to: Date())! } + static func isSameDay(_ date1: Date, _ date2: Date?) -> Bool { + guard let date2 = date2 else { return false } + return Calendar.current.isDate(date1, inSameDayAs: date2) + } + static var startOfDayTomorrow: Date { let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: Date())! return Calendar.current.startOfDay(for: tomorrow) diff --git a/Sources/SyncDataProviders/Credentials/CredentialsProvider.swift b/Sources/SyncDataProviders/Credentials/CredentialsProvider.swift index 1e9091456..020f711cd 100644 --- a/Sources/SyncDataProviders/Credentials/CredentialsProvider.swift +++ b/Sources/SyncDataProviders/Credentials/CredentialsProvider.swift @@ -209,6 +209,11 @@ public final class CredentialsProvider: DataProvider { } else { lastSyncLocalTimestamp = clientTimestamp } + + if !received.isEmpty { + NotificationCenter.default.post(name: .autofillSaveEvent, object: nil, userInfo: nil) + } + syncDidFinish() } diff --git a/Tests/BrowserServicesKitTests/Autofill/AutofillPixelReporterTests.swift b/Tests/BrowserServicesKitTests/Autofill/AutofillPixelReporterTests.swift new file mode 100644 index 000000000..e56a141ef --- /dev/null +++ b/Tests/BrowserServicesKitTests/Autofill/AutofillPixelReporterTests.swift @@ -0,0 +1,421 @@ +// +// AutofillPixelReporterTests.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 TestUtils +import Common +import SecureStorage +import SecureStorageTestsUtils +@testable import BrowserServicesKit + +final class AutofillPixelReporterTests: XCTestCase { + + private class MockEventMapping: EventMapping { + static var events: [AutofillPixelEvent] = [] + static var loginsParam: String? + static var creditCardsParam: String? + + public init() { + super.init { event, _, param, _ in + Self.events.append(event) + switch event { + case .autofillLoginsStacked: + Self.loginsParam = param?[AutofillPixelEvent.Parameter.countBucket] + case .autofillCreditCardsStacked: + Self.creditCardsParam = param?[AutofillPixelEvent.Parameter.countBucket] + default: + break + } + } + } + + override init(mapping: @escaping EventMapping.Mapping) { + fatalError("Use init()") + } + } + + private var mockCryptoProvider = MockCryptoProvider() + private var mockDatabaseProvider = (try! MockAutofillDatabaseProvider()) + private var mockKeystoreProvider = MockKeystoreProvider() + private var vault: (any AutofillSecureVault)! + private var eventMapping: MockEventMapping! + private var userDefaults: UserDefaults! + private let testGroupName = "autofill-reporter" + + override func setUpWithError() throws { + try super.setUpWithError() + + userDefaults = UserDefaults(suiteName: testGroupName)! + userDefaults.removePersistentDomain(forName: testGroupName) + + let providers = SecureStorageProviders(crypto: mockCryptoProvider, + database: mockDatabaseProvider, + keystore: mockKeystoreProvider) + + vault = DefaultAutofillSecureVault(providers: providers) + + eventMapping = MockEventMapping() + MockEventMapping.events.removeAll() + } + + override func tearDownWithError() throws { + vault = nil + eventMapping = nil + userDefaults.removePersistentDomain(forName: testGroupName) + + try super.tearDownWithError() + } + + func testWhenFirstFillAndSearchDauIsNotTodayThenNoEventsAreFired() { + let autofillPixelReporter = createAutofillPixelReporter() + autofillPixelReporter.resetStoreDefaults() + + NotificationCenter.default.post(name: .autofillFillEvent, object: nil) + + XCTAssertEqual(MockEventMapping.events.count, 0) + } + + func testWhenFirstFillAndSearchDauIsTodayAndAccountsCountIsZeroThenThreeEventsAreFiredWithNoneParams() { + let autofillPixelReporter = createAutofillPixelReporter() + autofillPixelReporter.resetStoreDefaults() + setAutofillSearchDauDate(daysAgo: 0) + + NotificationCenter.default.post(name: .autofillFillEvent, object: nil) + + XCTAssertEqual(MockEventMapping.events.count, 3) + XCTAssertTrue(MockEventMapping.events.contains(.autofillActiveUser)) + XCTAssertTrue(MockEventMapping.events.contains(.autofillLoginsStacked)) + XCTAssertTrue(MockEventMapping.events.contains(.autofillCreditCardsStacked)) + XCTAssertEqual(MockEventMapping.loginsParam, AutofillPixelReporter.BucketName.none.rawValue) + XCTAssertEqual(MockEventMapping.creditCardsParam, AutofillPixelReporter.BucketName.none.rawValue) + } + + func testWhenFirstSearchDauAndFillDateIsNotTodayAndAccountsCountIsZeroThenNoEventsAreFired() { + let autofillPixelReporter = createAutofillPixelReporter() + autofillPixelReporter.resetStoreDefaults() + createAccountsInVault(count: 0) + + NotificationCenter.default.post(name: .searchDAU, object: nil) + + XCTAssertEqual(MockEventMapping.events.count, 0) + } + + func testWhenFirstSearchDauAndFillDateIsNotTodayAndAndAccountsCountIsTenThenThenOneEventIsFired() throws { + let autofillPixelReporter = createAutofillPixelReporter() + autofillPixelReporter.resetStoreDefaults() + createAccountsInVault(count: 10) + + NotificationCenter.default.post(name: .searchDAU, object: nil) + + XCTAssertEqual(MockEventMapping.events.count, 1) + let event = try XCTUnwrap(MockEventMapping.events.first) + XCTAssertEqual(event, .autofillEnabledUser) + } + + func testWhenFirstSearchDauAndThenFirstFillAndAccountsCountIsZeroThenThreeEventsAreFiredWithNoneParams() { + let autofillPixelReporter = createAutofillPixelReporter() + autofillPixelReporter.resetStoreDefaults() + createAccountsInVault(count: 0) + + NotificationCenter.default.post(name: .searchDAU, object: nil) + NotificationCenter.default.post(name: .autofillFillEvent, object: nil) + + XCTAssertEqual(MockEventMapping.events.count, 3) + XCTAssertTrue(MockEventMapping.events.contains(.autofillActiveUser)) + XCTAssertTrue(MockEventMapping.events.contains(.autofillLoginsStacked)) + XCTAssertTrue(MockEventMapping.events.contains(.autofillCreditCardsStacked)) + XCTAssertEqual(MockEventMapping.loginsParam, AutofillPixelReporter.BucketName.none.rawValue) + XCTAssertEqual(MockEventMapping.creditCardsParam, AutofillPixelReporter.BucketName.none.rawValue) + } + + func testWhenFirstSearchDauAndThenFirstFillAndAccountsCountIsThreeThenTwoEventsAreFiredWithCorrectParams() { + let autofillPixelReporter = createAutofillPixelReporter() + autofillPixelReporter.resetStoreDefaults() + createAccountsInVault(count: 3) + + NotificationCenter.default.post(name: .searchDAU, object: nil) + NotificationCenter.default.post(name: .autofillFillEvent, object: nil) + + XCTAssertEqual(MockEventMapping.events.count, 3) + XCTAssertTrue(MockEventMapping.events.contains(.autofillActiveUser)) + XCTAssertTrue(MockEventMapping.events.contains(.autofillLoginsStacked)) + XCTAssertTrue(MockEventMapping.events.contains(.autofillCreditCardsStacked)) + XCTAssertEqual(MockEventMapping.loginsParam, AutofillPixelReporter.BucketName.few.rawValue) + XCTAssertEqual(MockEventMapping.creditCardsParam, AutofillPixelReporter.BucketName.none.rawValue) + } + + func testWhenFirstSearchDauAndThenFirstFillAndAccountsCountIsTenThenFourEventsAreFiredWithCorrectParams() { + let autofillPixelReporter = createAutofillPixelReporter() + autofillPixelReporter.resetStoreDefaults() + createAccountsInVault(count: 10) + + NotificationCenter.default.post(name: .searchDAU, object: nil) + NotificationCenter.default.post(name: .autofillFillEvent, object: nil) + + XCTAssertEqual(MockEventMapping.events.count, 4) + XCTAssertTrue(MockEventMapping.events.contains(.autofillActiveUser)) + XCTAssertTrue(MockEventMapping.events.contains(.autofillLoginsStacked)) + XCTAssertTrue(MockEventMapping.events.contains(.autofillCreditCardsStacked)) + XCTAssertTrue(MockEventMapping.events.contains(.autofillEnabledUser)) + XCTAssertEqual(MockEventMapping.loginsParam, AutofillPixelReporter.BucketName.some.rawValue) + XCTAssertEqual(MockEventMapping.creditCardsParam, AutofillPixelReporter.BucketName.none.rawValue) + } + + func testWhenFirstSearchDauAndThenFirstFillAndAccountsCountIsElevenThenFourEventsAreFiredWithManyParam() { + let autofillPixelReporter = createAutofillPixelReporter() + autofillPixelReporter.resetStoreDefaults() + createAccountsInVault(count: 11) + + NotificationCenter.default.post(name: .searchDAU, object: nil) + NotificationCenter.default.post(name: .autofillFillEvent, object: nil) + + XCTAssertEqual(MockEventMapping.events.count, 4) + XCTAssertTrue(MockEventMapping.events.contains(.autofillActiveUser)) + XCTAssertTrue(MockEventMapping.events.contains(.autofillLoginsStacked)) + XCTAssertTrue(MockEventMapping.events.contains(.autofillCreditCardsStacked)) + XCTAssertTrue(MockEventMapping.events.contains(.autofillEnabledUser)) + XCTAssertEqual(MockEventMapping.loginsParam, AutofillPixelReporter.BucketName.many.rawValue) + XCTAssertEqual(MockEventMapping.creditCardsParam, AutofillPixelReporter.BucketName.none.rawValue) + } + + func testWhenFirstSearchDauAndThenFirstFillAndAccountsCountIsFortyThenFourEventsAreFiredWithCorrectParams() { + let autofillPixelReporter = createAutofillPixelReporter() + autofillPixelReporter.resetStoreDefaults() + createAccountsInVault(count: 40) + + NotificationCenter.default.post(name: .searchDAU, object: nil) + NotificationCenter.default.post(name: .autofillFillEvent, object: nil) + + XCTAssertEqual(MockEventMapping.events.count, 4) + XCTAssertTrue(MockEventMapping.events.contains(.autofillActiveUser)) + XCTAssertTrue(MockEventMapping.events.contains(.autofillLoginsStacked)) + XCTAssertTrue(MockEventMapping.events.contains(.autofillEnabledUser)) + XCTAssertEqual(MockEventMapping.loginsParam, AutofillPixelReporter.BucketName.many.rawValue) + XCTAssertEqual(MockEventMapping.creditCardsParam, AutofillPixelReporter.BucketName.none.rawValue) + } + + func testWhenFirstSearchDauAndThenFirstFillAndAccountsCountIsFiftyThenFourEventsAreFiredWithCorrectParams() { + let autofillPixelReporter = createAutofillPixelReporter() + autofillPixelReporter.resetStoreDefaults() + createAccountsInVault(count: 50) + + NotificationCenter.default.post(name: .searchDAU, object: nil) + NotificationCenter.default.post(name: .autofillFillEvent, object: nil) + + XCTAssertEqual(MockEventMapping.events.count, 4) + XCTAssertTrue(MockEventMapping.events.contains(.autofillActiveUser)) + XCTAssertTrue(MockEventMapping.events.contains(.autofillLoginsStacked)) + XCTAssertTrue(MockEventMapping.events.contains(.autofillCreditCardsStacked)) + XCTAssertTrue(MockEventMapping.events.contains(.autofillEnabledUser)) + XCTAssertEqual(MockEventMapping.loginsParam, AutofillPixelReporter.BucketName.lots.rawValue) + XCTAssertEqual(MockEventMapping.creditCardsParam, AutofillPixelReporter.BucketName.none.rawValue) + } + + func testWhenFirstSearchDauAndThenFirstFillAndCreditCardsCountIsOneThenThreeEventsAreFiredWithCorrectParams() { + let autofillPixelReporter = createAutofillPixelReporter() + autofillPixelReporter.resetStoreDefaults() + createAccountsInVault(count: 0) + createCreditCardsInVault(count: 1) + + NotificationCenter.default.post(name: .searchDAU, object: nil) + NotificationCenter.default.post(name: .autofillFillEvent, object: nil) + + XCTAssertEqual(MockEventMapping.events.count, 3) + XCTAssertTrue(MockEventMapping.events.contains(.autofillActiveUser)) + XCTAssertTrue(MockEventMapping.events.contains(.autofillLoginsStacked)) + XCTAssertTrue(MockEventMapping.events.contains(.autofillCreditCardsStacked)) + XCTAssertEqual(MockEventMapping.loginsParam, AutofillPixelReporter.BucketName.none.rawValue) + XCTAssertEqual(MockEventMapping.creditCardsParam, AutofillPixelReporter.BucketName.some.rawValue) + } + + func testWhenFirstSearchDauAndThenFirstFillAndCreditCardsCountIsThreeThenThreeEventsAreFiredWithCorrectParams() { + let autofillPixelReporter = createAutofillPixelReporter() + autofillPixelReporter.resetStoreDefaults() + createAccountsInVault(count: 0) + createCreditCardsInVault(count: 3) + + NotificationCenter.default.post(name: .searchDAU, object: nil) + NotificationCenter.default.post(name: .autofillFillEvent, object: nil) + + XCTAssertEqual(MockEventMapping.events.count, 3) + XCTAssertTrue(MockEventMapping.events.contains(.autofillActiveUser)) + XCTAssertTrue(MockEventMapping.events.contains(.autofillLoginsStacked)) + XCTAssertTrue(MockEventMapping.events.contains(.autofillCreditCardsStacked)) + XCTAssertEqual(MockEventMapping.loginsParam, AutofillPixelReporter.BucketName.none.rawValue) + XCTAssertEqual(MockEventMapping.creditCardsParam, AutofillPixelReporter.BucketName.some.rawValue) + } + + func testWhenFirstSearchDauAndThenFirstFillAndCreditCardsCountIsFourThenThreeEventsAreFiredWithCorrectParams() { + let autofillPixelReporter = createAutofillPixelReporter() + autofillPixelReporter.resetStoreDefaults() + createAccountsInVault(count: 0) + createCreditCardsInVault(count: 4) + + NotificationCenter.default.post(name: .searchDAU, object: nil) + NotificationCenter.default.post(name: .autofillFillEvent, object: nil) + + XCTAssertEqual(MockEventMapping.events.count, 3) + XCTAssertTrue(MockEventMapping.events.contains(.autofillActiveUser)) + XCTAssertTrue(MockEventMapping.events.contains(.autofillLoginsStacked)) + XCTAssertTrue(MockEventMapping.events.contains(.autofillCreditCardsStacked)) + XCTAssertEqual(MockEventMapping.loginsParam, AutofillPixelReporter.BucketName.none.rawValue) + XCTAssertEqual(MockEventMapping.creditCardsParam, AutofillPixelReporter.BucketName.many.rawValue) + } + + func testWhenSubsequentFillAndSearchDauIsNotTodayThenNoEventsAreFired() { + let autofillPixelReporter = createAutofillPixelReporter() + autofillPixelReporter.resetStoreDefaults() + setAutofillSearchDauDate(daysAgo: 1) + + NotificationCenter.default.post(name: .autofillFillEvent, object: nil) + + XCTAssertEqual(MockEventMapping.events.count, 0) + } + + func testWhenSubsequentFillAndSearchDauIsTodayThenNoEventsAreFired() { + let autofillPixelReporter = createAutofillPixelReporter() + autofillPixelReporter.resetStoreDefaults() + setAutofillSearchDauDate(daysAgo: 0) + setAutofillFillDate(daysAgo: 0) + + NotificationCenter.default.post(name: .autofillFillEvent, object: nil) + + XCTAssertEqual(MockEventMapping.events.count, 0) + } + + func testWhenSubsequentSearchDauAndFillDateIsNotTodayThenNoEventsAreFired() { + let autofillPixelReporter = createAutofillPixelReporter() + autofillPixelReporter.resetStoreDefaults() + setAutofillSearchDauDate(daysAgo: 0) + + NotificationCenter.default.post(name: .searchDAU, object: nil) + XCTAssertEqual(MockEventMapping.events.count, 0) + } + + func testWhenSubsequentSearchDauAndFillDateIsTodayThenNoEventsAreFired() { + let autofillPixelReporter = createAutofillPixelReporter() + autofillPixelReporter.resetStoreDefaults() + setAutofillSearchDauDate(daysAgo: 0) + setAutofillFillDate(daysAgo: 0) + + NotificationCenter.default.post(name: .searchDAU, object: nil) + + XCTAssertEqual(MockEventMapping.events.count, 0) + } + + func testWhenSaveAndUserIsAlreadyOnboardedThenOnboardedUserPixelShouldNotBeFired() { + let autofillPixelReporter = createAutofillPixelReporter(installDate: Date().addingTimeInterval(.days(-1))) + autofillPixelReporter.resetStoreDefaults() + userDefaults.set(true, forKey: AutofillPixelReporter.Keys.autofillOnboardedUserKey) + + NotificationCenter.default.post(name: .autofillSaveEvent, object: nil) + + XCTAssertEqual(MockEventMapping.events.count, 0) + } + + func testWhenSaveAndNotOnboardedAndInstallDateIsNilThenOnboardedUserPixelShouldNotBeFired() { + let autofillPixelReporter = createAutofillPixelReporter(installDate: nil) + autofillPixelReporter.resetStoreDefaults() + + NotificationCenter.default.post(name: .autofillSaveEvent, object: nil) + + XCTAssertEqual(MockEventMapping.events.count, 0) + } + + func testWhenUserIsNotOnboardedAndInstallDateIsLessThanSevenDaysAgoAndAccountsCountIsZeroThenOnboardedUserPixelShouldNotBeFired() { + let autofillPixelReporter = createAutofillPixelReporter(installDate: Date().addingTimeInterval(.days(-4))) + autofillPixelReporter.resetStoreDefaults() + createAccountsInVault(count: 0) + + NotificationCenter.default.post(name: .autofillSaveEvent, object: nil) + + XCTAssertEqual(MockEventMapping.events.count, 0) + } + + func testWhenUserIsNotOnboardedAndInstallDateIsLessThanSevenDaysAgoAndAccountsCountIsGreaterThanZeroThenOnboardedUserPixelShouldBeFiredAndAutofillOnboardedUserShouldBeTrue() throws { + let autofillPixelReporter = createAutofillPixelReporter(installDate: Date().addingTimeInterval(.days(-4))) + autofillPixelReporter.resetStoreDefaults() + createAccountsInVault(count: 1) + + NotificationCenter.default.post(name: .autofillSaveEvent, object: nil) + + XCTAssertEqual(MockEventMapping.events.count, 1) + let event = try XCTUnwrap(MockEventMapping.events.first) + XCTAssertEqual(event, .autofillOnboardedUser) + let onboardedState = try XCTUnwrap(getAutofillOnboardedUserState()) + XCTAssertTrue(onboardedState) + } + + func testWhenUserIsNotOnboardedAndInstallDateIsGreaterThanSevenDaysAgoThenOnboardedUserPixelShouldNotBeFiredAndAutofillOnboardedUserShouldBeTrue() throws { + let autofillPixelReporter = createAutofillPixelReporter(installDate: Date().addingTimeInterval(.days(-8))) + autofillPixelReporter.resetStoreDefaults() + + NotificationCenter.default.post(name: .autofillSaveEvent, object: nil) + + XCTAssertEqual(MockEventMapping.events.count, 0) + let onboardedState = try XCTUnwrap(getAutofillOnboardedUserState()) + XCTAssertTrue(onboardedState) + } + + private func createAutofillPixelReporter(installDate: Date? = Date()) -> AutofillPixelReporter { + return AutofillPixelReporter(userDefaults: userDefaults, + eventMapping: eventMapping, + secureVault: vault, + installDate: installDate) + } + + private func createAccountsInVault(count: Int) { + try? vault.deleteAllWebsiteCredentials() + + for i in 0.. Bool? { + return userDefaults.object(forKey: AutofillPixelReporter.Keys.autofillOnboardedUserKey) as? Bool + } + +} diff --git a/Tests/BrowserServicesKitTests/SecureVault/MockAutofillDatabaseProvider.swift b/Tests/BrowserServicesKitTests/SecureVault/MockAutofillDatabaseProvider.swift index 7e674a1bf..4c767289e 100644 --- a/Tests/BrowserServicesKitTests/SecureVault/MockAutofillDatabaseProvider.swift +++ b/Tests/BrowserServicesKitTests/SecureVault/MockAutofillDatabaseProvider.swift @@ -142,6 +142,10 @@ internal class MockAutofillDatabaseProvider: AutofillDatabaseProvider { return Array(_creditCards.values) } + func creditCardsCount() throws -> Int { + return _creditCards.count + } + func creditCardForCardId(_ cardId: Int64) throws -> SecureVaultModels.CreditCard? { return _creditCards[cardId] } From 610a58a77fefe82f8541d4a7f998ef2a4609a068 Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Thu, 23 May 2024 21:59:27 -0700 Subject: [PATCH 5/7] Add `survey` action and Privacy Pro attributes (#826) Task/Issue URL: https://app.asana.com/0/1193060753475688/1207234800675206/f iOS PR: duckduckgo/iOS#2879 macOS PR: duckduckgo/macos-browser#2798 What kind of version bump will this require?: Major Description: This PR adds two things to RMF: A new survey action had been added, with configurable parameters New Privacy Pro attributes have been added --- .gitignore | 1 + .../JsonToRemoteConfigModelMapper.swift | 8 ++- .../JsonToRemoteMessageModelMapper.swift | 48 +++++++++---- .../RemoteMessagingSurveyActionMapping.swift | 35 ++++++++++ .../Matchers/UserAttributeMatcher.swift | 29 +++++--- .../Model/JsonRemoteMessagingConfig.swift | 2 +- .../Model/MatchingAttributes.swift | 67 +++++++++++++------ .../Model/RemoteMessageModel.swift | 2 +- .../RemoteMessagingConfigMatcher.swift | 3 + .../RemoteMessagingConfigProcessor.swift | 5 +- .../JsonToRemoteConfigModelMapperTests.swift | 24 +++++-- .../Matchers/UserAttributeMatcherTests.swift | 33 ++++++--- .../MockRemoteMessageSurveyActionMapper.swift | 28 ++++++++ .../RemoteMessagingConfigMatcherTests.swift | 51 +++++++++----- .../RemoteMessagingConfigProcessorTests.swift | 12 ++-- .../Resources/remote-messaging-config.json | 33 +++++++-- 16 files changed, 290 insertions(+), 91 deletions(-) create mode 100644 Sources/RemoteMessaging/Mappers/RemoteMessagingSurveyActionMapping.swift create mode 100644 Tests/BrowserServicesKitTests/RemoteMessaging/Mocks/MockRemoteMessageSurveyActionMapper.swift diff --git a/.gitignore b/.gitignore index 7aafd7b2d..fdb78a2ca 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ .DS_Store xcuserdata/ .vscode +*.swift.plist diff --git a/Sources/RemoteMessaging/Mappers/JsonToRemoteConfigModelMapper.swift b/Sources/RemoteMessaging/Mappers/JsonToRemoteConfigModelMapper.swift index 56caf5c64..981db0ee1 100644 --- a/Sources/RemoteMessaging/Mappers/JsonToRemoteConfigModelMapper.swift +++ b/Sources/RemoteMessaging/Mappers/JsonToRemoteConfigModelMapper.swift @@ -21,8 +21,12 @@ import Foundation struct JsonToRemoteConfigModelMapper { - static func mapJson(remoteMessagingConfig: RemoteMessageResponse.JsonRemoteMessagingConfig) -> RemoteConfigModel { - let remoteMessages = JsonToRemoteMessageModelMapper.maps(jsonRemoteMessages: remoteMessagingConfig.messages) + static func mapJson(remoteMessagingConfig: RemoteMessageResponse.JsonRemoteMessagingConfig, + surveyActionMapper: RemoteMessagingSurveyActionMapping) -> RemoteConfigModel { + let remoteMessages = JsonToRemoteMessageModelMapper.maps( + jsonRemoteMessages: remoteMessagingConfig.messages, + surveyActionMapper: surveyActionMapper + ) os_log("remoteMessages mapped = %s", log: .remoteMessaging, type: .debug, String(describing: remoteMessages)) let rules = JsonToRemoteMessageModelMapper.maps(jsonRemoteRules: remoteMessagingConfig.rules) os_log("rules mapped = %s", log: .remoteMessaging, type: .debug, String(describing: rules)) diff --git a/Sources/RemoteMessaging/Mappers/JsonToRemoteMessageModelMapper.swift b/Sources/RemoteMessaging/Mappers/JsonToRemoteMessageModelMapper.swift index 88af0abce..c84074299 100644 --- a/Sources/RemoteMessaging/Mappers/JsonToRemoteMessageModelMapper.swift +++ b/Sources/RemoteMessaging/Mappers/JsonToRemoteMessageModelMapper.swift @@ -36,8 +36,9 @@ private enum AttributesKey: String, CaseIterable { case favorites case appTheme case daysSinceInstalled - case isNetPWaitlistUser case daysSinceNetPEnabled + case pproEligible + case pproSubscriber func matchingAttribute(jsonMatchingAttribute: AnyDecodable) -> MatchingAttribute { switch self { @@ -56,8 +57,9 @@ private enum AttributesKey: String, CaseIterable { case .favorites: return FavoritesMatchingAttribute(jsonMatchingAttribute: jsonMatchingAttribute) case .appTheme: return AppThemeMatchingAttribute(jsonMatchingAttribute: jsonMatchingAttribute) case .daysSinceInstalled: return DaysSinceInstalledMatchingAttribute(jsonMatchingAttribute: jsonMatchingAttribute) - case .isNetPWaitlistUser: return IsNetPWaitlistUserMatchingAttribute(jsonMatchingAttribute: jsonMatchingAttribute) case .daysSinceNetPEnabled: return DaysSinceNetPEnabledMatchingAttribute(jsonMatchingAttribute: jsonMatchingAttribute) + case .pproEligible: return IsPrivacyProEligibleUserMatchingAttribute(jsonMatchingAttribute: jsonMatchingAttribute) + case .pproSubscriber: return IsPrivacyProSubscriberUserMatchingAttribute(jsonMatchingAttribute: jsonMatchingAttribute) } } } @@ -65,11 +67,16 @@ private enum AttributesKey: String, CaseIterable { struct JsonToRemoteMessageModelMapper { - static func maps(jsonRemoteMessages: [RemoteMessageResponse.JsonRemoteMessage]) -> [RemoteMessageModel] { + static func maps(jsonRemoteMessages: [RemoteMessageResponse.JsonRemoteMessage], + surveyActionMapper: RemoteMessagingSurveyActionMapping) -> [RemoteMessageModel] { var remoteMessages: [RemoteMessageModel] = [] jsonRemoteMessages.forEach { message in + guard let content = mapToContent( content: message.content, surveyActionMapper: surveyActionMapper) else { + return + } + var remoteMessage = RemoteMessageModel(id: message.id, - content: mapToContent(content: message.content), + content: content, matchingRules: message.matchingRules ?? [], exclusionRules: message.exclusionRules ?? []) @@ -83,7 +90,8 @@ struct JsonToRemoteMessageModelMapper { } // swiftlint:disable cyclomatic_complexity function_body_length - static func mapToContent(content: RemoteMessageResponse.JsonContent) -> RemoteMessageModelType? { + static func mapToContent(content: RemoteMessageResponse.JsonContent, + surveyActionMapper: RemoteMessagingSurveyActionMapping) -> RemoteMessageModelType? { switch RemoteMessageResponse.JsonMessageType(rawValue: content.messageType) { case .small: guard !content.titleText.isEmpty, !content.descriptionText.isEmpty else { @@ -103,7 +111,7 @@ struct JsonToRemoteMessageModelMapper { case .bigSingleAction: guard let primaryActionText = content.primaryActionText, !primaryActionText.isEmpty, - let action = mapToAction(content.primaryAction) + let action = mapToAction(content.primaryAction, surveyActionMapper: surveyActionMapper) else { return nil } @@ -116,10 +124,10 @@ struct JsonToRemoteMessageModelMapper { case .bigTwoAction: guard let primaryActionText = content.primaryActionText, !primaryActionText.isEmpty, - let primaryAction = mapToAction(content.primaryAction), + let primaryAction = mapToAction(content.primaryAction, surveyActionMapper: surveyActionMapper), let secondaryActionText = content.secondaryActionText, !secondaryActionText.isEmpty, - let secondaryAction = mapToAction(content.secondaryAction) + let secondaryAction = mapToAction(content.secondaryAction, surveyActionMapper: surveyActionMapper) else { return nil } @@ -134,7 +142,7 @@ struct JsonToRemoteMessageModelMapper { case .promoSingleAction: guard let actionText = content.actionText, !actionText.isEmpty, - let action = mapToAction(content.action) + let action = mapToAction(content.action, surveyActionMapper: surveyActionMapper) else { return nil } @@ -151,7 +159,8 @@ struct JsonToRemoteMessageModelMapper { } // swiftlint:enable cyclomatic_complexity function_body_length - static func mapToAction(_ jsonAction: RemoteMessageResponse.JsonMessageAction?) -> RemoteAction? { + static func mapToAction(_ jsonAction: RemoteMessageResponse.JsonMessageAction?, + surveyActionMapper: RemoteMessagingSurveyActionMapping) -> RemoteAction? { guard let jsonAction = jsonAction else { return nil } @@ -161,8 +170,23 @@ struct JsonToRemoteMessageModelMapper { return .share(value: jsonAction.value, title: jsonAction.additionalParameters?["title"]) case .url: return .url(value: jsonAction.value) - case .surveyURL: - return .surveyURL(value: jsonAction.value) + case .survey: + if let queryParamsString = jsonAction.additionalParameters?["queryParams"] as? String { + let queryParams = queryParamsString.components(separatedBy: ";") + let mappedQueryParams = queryParams.compactMap { param in + return RemoteMessagingSurveyActionParameter(rawValue: param) + } + + if mappedQueryParams.count == queryParams.count, let surveyURL = URL(string: jsonAction.value) { + let updatedURL = surveyActionMapper.add(parameters: mappedQueryParams, to: surveyURL) + return .survey(value: updatedURL.absoluteString) + } else { + // The message requires a parameter that isn't supported + return nil + } + } else { + return .survey(value: jsonAction.value) + } case .appStore: return .appStore case .dismiss: diff --git a/Sources/RemoteMessaging/Mappers/RemoteMessagingSurveyActionMapping.swift b/Sources/RemoteMessaging/Mappers/RemoteMessagingSurveyActionMapping.swift new file mode 100644 index 000000000..243ee6a79 --- /dev/null +++ b/Sources/RemoteMessaging/Mappers/RemoteMessagingSurveyActionMapping.swift @@ -0,0 +1,35 @@ +// +// RemoteMessagingSurveyActionMapping.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 Foundation + +public enum RemoteMessagingSurveyActionParameter: String, CaseIterable { + case appVersion = "ddgv" + case atb = "atb" + case atbVariant = "var" + case daysInstalled = "delta" + case hardwareModel = "mo" + case lastActiveDate = "da" + case osVersion = "osv" +} + +public protocol RemoteMessagingSurveyActionMapping { + + func add(parameters: [RemoteMessagingSurveyActionParameter], to url: URL) -> URL + +} diff --git a/Sources/RemoteMessaging/Matchers/UserAttributeMatcher.swift b/Sources/RemoteMessaging/Matchers/UserAttributeMatcher.swift index a4043560f..f80f1d4ae 100644 --- a/Sources/RemoteMessaging/Matchers/UserAttributeMatcher.swift +++ b/Sources/RemoteMessaging/Matchers/UserAttributeMatcher.swift @@ -29,8 +29,9 @@ public struct UserAttributeMatcher: AttributeMatcher { private let bookmarksCount: Int private let favoritesCount: Int private let isWidgetInstalled: Bool - private let isNetPWaitlistUser: Bool private let daysSinceNetPEnabled: Int + private let isPrivacyProEligibleUser: Bool + private let isPrivacyProSubscriber: Bool public init(statisticsStore: StatisticsStore, variantManager: VariantManager, @@ -39,8 +40,9 @@ public struct UserAttributeMatcher: AttributeMatcher { favoritesCount: Int, appTheme: String, isWidgetInstalled: Bool, - isNetPWaitlistUser: Bool, - daysSinceNetPEnabled: Int + daysSinceNetPEnabled: Int, + isPrivacyProEligibleUser: Bool, + isPrivacyProSubscriber: Bool ) { self.statisticsStore = statisticsStore self.variantManager = variantManager @@ -49,8 +51,9 @@ public struct UserAttributeMatcher: AttributeMatcher { self.bookmarksCount = bookmarksCount self.favoritesCount = favoritesCount self.isWidgetInstalled = isWidgetInstalled - self.isNetPWaitlistUser = isNetPWaitlistUser self.daysSinceNetPEnabled = daysSinceNetPEnabled + self.isPrivacyProEligibleUser = isPrivacyProEligibleUser + self.isPrivacyProSubscriber = isPrivacyProSubscriber } // swiftlint:disable:next cyclomatic_complexity function_body_length @@ -97,18 +100,24 @@ public struct UserAttributeMatcher: AttributeMatcher { } return BooleanMatchingAttribute(value).matches(value: isWidgetInstalled) - case let matchingAttribute as IsNetPWaitlistUserMatchingAttribute: - guard let value = matchingAttribute.value else { - return .fail - } - - return BooleanMatchingAttribute(value).matches(value: isNetPWaitlistUser) case let matchingAttribute as DaysSinceNetPEnabledMatchingAttribute: if matchingAttribute.value != MatchingAttributeDefaults.intDefaultValue { return IntMatchingAttribute(matchingAttribute.value).matches(value: daysSinceNetPEnabled) } else { return RangeIntMatchingAttribute(min: matchingAttribute.min, max: matchingAttribute.max).matches(value: daysSinceNetPEnabled) } + case let matchingAttribute as IsPrivacyProEligibleUserMatchingAttribute: + guard let value = matchingAttribute.value else { + return .fail + } + + return BooleanMatchingAttribute(value).matches(value: isPrivacyProEligibleUser) + case let matchingAttribute as IsPrivacyProSubscriberUserMatchingAttribute: + guard let value = matchingAttribute.value else { + return .fail + } + + return BooleanMatchingAttribute(value).matches(value: isPrivacyProSubscriber) default: assertionFailure("Could not find matching attribute") return nil diff --git a/Sources/RemoteMessaging/Model/JsonRemoteMessagingConfig.swift b/Sources/RemoteMessaging/Model/JsonRemoteMessagingConfig.swift index eda32d920..78b3e2a24 100644 --- a/Sources/RemoteMessaging/Model/JsonRemoteMessagingConfig.swift +++ b/Sources/RemoteMessaging/Model/JsonRemoteMessagingConfig.swift @@ -87,7 +87,7 @@ public enum RemoteMessageResponse { case url case appStore = "appstore" case dismiss - case surveyURL = "survey_url" + case survey = "survey" } enum JsonPlaceholder: String, CaseIterable { diff --git a/Sources/RemoteMessaging/Model/MatchingAttributes.swift b/Sources/RemoteMessaging/Model/MatchingAttributes.swift index 7f6551c18..7fc275706 100644 --- a/Sources/RemoteMessaging/Model/MatchingAttributes.swift +++ b/Sources/RemoteMessaging/Model/MatchingAttributes.swift @@ -608,7 +608,45 @@ struct RangeStringNumericMatchingAttribute: Equatable { } } -struct IsNetPWaitlistUserMatchingAttribute: MatchingAttribute, Equatable { +struct DaysSinceNetPEnabledMatchingAttribute: MatchingAttribute, Equatable { + var min: Int = MatchingAttributeDefaults.intDefaultValue + var max: Int = MatchingAttributeDefaults.intDefaultMaxValue + var value: Int = MatchingAttributeDefaults.intDefaultValue + var fallback: Bool? + + init(jsonMatchingAttribute: AnyDecodable) { + guard let jsonMatchingAttribute = jsonMatchingAttribute.value as? [String: Any] else { return } + + if let min = jsonMatchingAttribute[RuleAttributes.min] as? Int { + self.min = min + } + if let max = jsonMatchingAttribute[RuleAttributes.max] as? Int { + self.max = max + } + if let value = jsonMatchingAttribute[RuleAttributes.value] as? Int { + self.value = value + } + if let fallback = jsonMatchingAttribute[RuleAttributes.fallback] as? Bool { + self.fallback = fallback + } + } + + init(min: Int = MatchingAttributeDefaults.intDefaultValue, + max: Int = MatchingAttributeDefaults.intDefaultMaxValue, + value: Int = MatchingAttributeDefaults.intDefaultValue, + fallback: Bool?) { + self.min = min + self.max = max + self.value = value + self.fallback = fallback + } + + static func == (lhs: DaysSinceNetPEnabledMatchingAttribute, rhs: DaysSinceNetPEnabledMatchingAttribute) -> Bool { + return lhs.min == rhs.min && lhs.max == rhs.max && lhs.value == rhs.value && lhs.fallback == rhs.fallback + } +} + +struct IsPrivacyProEligibleUserMatchingAttribute: MatchingAttribute, Equatable { var value: Bool? var fallback: Bool? @@ -628,27 +666,19 @@ struct IsNetPWaitlistUserMatchingAttribute: MatchingAttribute, Equatable { self.fallback = fallback } - static func == (lhs: IsNetPWaitlistUserMatchingAttribute, rhs: IsNetPWaitlistUserMatchingAttribute) -> Bool { + static func == (lhs: IsPrivacyProEligibleUserMatchingAttribute, rhs: IsPrivacyProEligibleUserMatchingAttribute) -> Bool { return lhs.value == rhs.value && lhs.fallback == rhs.fallback } } -struct DaysSinceNetPEnabledMatchingAttribute: MatchingAttribute, Equatable { - var min: Int = MatchingAttributeDefaults.intDefaultValue - var max: Int = MatchingAttributeDefaults.intDefaultMaxValue - var value: Int = MatchingAttributeDefaults.intDefaultValue +struct IsPrivacyProSubscriberUserMatchingAttribute: MatchingAttribute, Equatable { + var value: Bool? var fallback: Bool? init(jsonMatchingAttribute: AnyDecodable) { guard let jsonMatchingAttribute = jsonMatchingAttribute.value as? [String: Any] else { return } - if let min = jsonMatchingAttribute[RuleAttributes.min] as? Int { - self.min = min - } - if let max = jsonMatchingAttribute[RuleAttributes.max] as? Int { - self.max = max - } - if let value = jsonMatchingAttribute[RuleAttributes.value] as? Int { + if let value = jsonMatchingAttribute[RuleAttributes.value] as? Bool { self.value = value } if let fallback = jsonMatchingAttribute[RuleAttributes.fallback] as? Bool { @@ -656,18 +686,13 @@ struct DaysSinceNetPEnabledMatchingAttribute: MatchingAttribute, Equatable { } } - init(min: Int = MatchingAttributeDefaults.intDefaultValue, - max: Int = MatchingAttributeDefaults.intDefaultMaxValue, - value: Int = MatchingAttributeDefaults.intDefaultValue, - fallback: Bool?) { - self.min = min - self.max = max + init(value: Bool?, fallback: Bool?) { self.value = value self.fallback = fallback } - static func == (lhs: DaysSinceNetPEnabledMatchingAttribute, rhs: DaysSinceNetPEnabledMatchingAttribute) -> Bool { - return lhs.min == rhs.min && lhs.max == rhs.max && lhs.value == rhs.value && lhs.fallback == rhs.fallback + static func == (lhs: IsPrivacyProSubscriberUserMatchingAttribute, rhs: IsPrivacyProSubscriberUserMatchingAttribute) -> Bool { + return lhs.value == rhs.value && lhs.fallback == rhs.fallback } } diff --git a/Sources/RemoteMessaging/Model/RemoteMessageModel.swift b/Sources/RemoteMessaging/Model/RemoteMessageModel.swift index 2bf6a39e0..2c0773ad6 100644 --- a/Sources/RemoteMessaging/Model/RemoteMessageModel.swift +++ b/Sources/RemoteMessaging/Model/RemoteMessageModel.swift @@ -102,7 +102,7 @@ public enum RemoteMessageModelType: Codable, Equatable { public enum RemoteAction: Codable, Equatable { case share(value: String, title: String?) case url(value: String) - case surveyURL(value: String) + case survey(value: String) case appStore case dismiss } diff --git a/Sources/RemoteMessaging/RemoteMessagingConfigMatcher.swift b/Sources/RemoteMessaging/RemoteMessagingConfigMatcher.swift index 23d4c9471..4df18d7a6 100644 --- a/Sources/RemoteMessaging/RemoteMessagingConfigMatcher.swift +++ b/Sources/RemoteMessaging/RemoteMessagingConfigMatcher.swift @@ -26,6 +26,7 @@ public struct RemoteMessagingConfigMatcher { private let userAttributeMatcher: UserAttributeMatcher private let percentileStore: RemoteMessagingPercentileStoring private let dismissedMessageIds: [String] + let surveyActionMapper: RemoteMessagingSurveyActionMapping private let matchers: [AttributeMatcher] @@ -33,11 +34,13 @@ public struct RemoteMessagingConfigMatcher { deviceAttributeMatcher: DeviceAttributeMatcher = DeviceAttributeMatcher(), userAttributeMatcher: UserAttributeMatcher, percentileStore: RemoteMessagingPercentileStoring, + surveyActionMapper: RemoteMessagingSurveyActionMapping, dismissedMessageIds: [String]) { self.appAttributeMatcher = appAttributeMatcher self.deviceAttributeMatcher = deviceAttributeMatcher self.userAttributeMatcher = userAttributeMatcher self.percentileStore = percentileStore + self.surveyActionMapper = surveyActionMapper self.dismissedMessageIds = dismissedMessageIds matchers = [appAttributeMatcher, deviceAttributeMatcher, userAttributeMatcher] diff --git a/Sources/RemoteMessaging/RemoteMessagingConfigProcessor.swift b/Sources/RemoteMessaging/RemoteMessagingConfigProcessor.swift index 233864732..e8afc324d 100644 --- a/Sources/RemoteMessaging/RemoteMessagingConfigProcessor.swift +++ b/Sources/RemoteMessaging/RemoteMessagingConfigProcessor.swift @@ -42,7 +42,10 @@ public struct RemoteMessagingConfigProcessor { let isNewVersion = newVersion != currentVersion if isNewVersion || shouldProcessConfig(currentConfig) { - let config = JsonToRemoteConfigModelMapper.mapJson(remoteMessagingConfig: jsonRemoteMessagingConfig) + let config = JsonToRemoteConfigModelMapper.mapJson( + remoteMessagingConfig: jsonRemoteMessagingConfig, + surveyActionMapper: remoteMessagingConfigMatcher.surveyActionMapper + ) let message = remoteMessagingConfigMatcher.evaluate(remoteConfig: config) os_log("Message to present next: %s", log: .remoteMessaging, type: .debug, message.debugDescription) diff --git a/Tests/BrowserServicesKitTests/RemoteMessaging/Mappers/JsonToRemoteConfigModelMapperTests.swift b/Tests/BrowserServicesKitTests/RemoteMessaging/Mappers/JsonToRemoteConfigModelMapperTests.swift index 4cf9b2bc7..f752066a3 100644 --- a/Tests/BrowserServicesKitTests/RemoteMessaging/Mappers/JsonToRemoteConfigModelMapperTests.swift +++ b/Tests/BrowserServicesKitTests/RemoteMessaging/Mappers/JsonToRemoteConfigModelMapperTests.swift @@ -92,7 +92,7 @@ class JsonToRemoteConfigModelMapperTests: XCTestCase { descriptionText: "Survey Description", placeholder: .vpnAnnounce, actionText: "Survey Action", - action: .surveyURL(value: "https://duckduckgo.com/survey") + action: .survey(value: "https://duckduckgo.com/survey") ), matchingRules: [8], exclusionRules: []) @@ -131,14 +131,24 @@ class JsonToRemoteConfigModelMapperTests: XCTestCase { let rule8 = config.rules.filter { $0.id == 8 }.first XCTAssertNotNil(rule8) XCTAssertNil(rule8?.targetPercentile) - XCTAssertTrue(rule8?.attributes.count == 2) + XCTAssertTrue(rule8?.attributes.count == 3) attribs = rule8?.attributes.filter { $0 is DaysSinceNetPEnabledMatchingAttribute } XCTAssertEqual(attribs?.count, 1) XCTAssertEqual(attribs?.first as? DaysSinceNetPEnabledMatchingAttribute, DaysSinceNetPEnabledMatchingAttribute(min: 5, fallback: nil)) - attribs = rule8?.attributes.filter { $0 is IsNetPWaitlistUserMatchingAttribute } + attribs = rule8?.attributes.filter { $0 is IsPrivacyProEligibleUserMatchingAttribute } XCTAssertEqual(attribs?.count, 1) - XCTAssertEqual(attribs?.first as? IsNetPWaitlistUserMatchingAttribute, IsNetPWaitlistUserMatchingAttribute(value: true, fallback: nil)) + XCTAssertEqual( + attribs?.first as? IsPrivacyProEligibleUserMatchingAttribute, + IsPrivacyProEligibleUserMatchingAttribute(value: true, fallback: nil) + ) + + attribs = rule8?.attributes.filter { $0 is IsPrivacyProSubscriberUserMatchingAttribute } + XCTAssertEqual(attribs?.count, 1) + XCTAssertEqual( + attribs?.first as? IsPrivacyProSubscriberUserMatchingAttribute, + IsPrivacyProSubscriberUserMatchingAttribute(value: true, fallback: nil) + ) let rule9 = config.rules.filter { $0.id == 9 }.first XCTAssertNotNil(rule9) @@ -173,8 +183,9 @@ class JsonToRemoteConfigModelMapperTests: XCTestCase { func testWhenJsonAttributeMissingThenUnknownIntoConfig() throws { let validJson = data.fromJsonFile("Resources/remote-messaging-config-malformed.json") let remoteMessagingConfig = try JSONDecoder().decode(RemoteMessageResponse.JsonRemoteMessagingConfig.self, from: validJson) + let surveyMapper = MockRemoteMessageSurveyActionMapper() XCTAssertNotNil(remoteMessagingConfig) - let config = JsonToRemoteConfigModelMapper.mapJson(remoteMessagingConfig: remoteMessagingConfig) + let config = JsonToRemoteConfigModelMapper.mapJson(remoteMessagingConfig: remoteMessagingConfig, surveyActionMapper: surveyMapper) XCTAssertTrue(config.rules.count == 2) let rule6 = config.rules.filter { $0.id == 6 }.first @@ -187,9 +198,10 @@ class JsonToRemoteConfigModelMapperTests: XCTestCase { func decodeAndMapJson(fileName: String) throws -> RemoteConfigModel { let validJson = data.fromJsonFile(fileName) let remoteMessagingConfig = try JSONDecoder().decode(RemoteMessageResponse.JsonRemoteMessagingConfig.self, from: validJson) + let surveyMapper = MockRemoteMessageSurveyActionMapper() XCTAssertNotNil(remoteMessagingConfig) - let config = JsonToRemoteConfigModelMapper.mapJson(remoteMessagingConfig: remoteMessagingConfig) + let config = JsonToRemoteConfigModelMapper.mapJson(remoteMessagingConfig: remoteMessagingConfig, surveyActionMapper: surveyMapper) XCTAssertNotNil(config) return config } diff --git a/Tests/BrowserServicesKitTests/RemoteMessaging/Matchers/UserAttributeMatcherTests.swift b/Tests/BrowserServicesKitTests/RemoteMessaging/Matchers/UserAttributeMatcherTests.swift index d8b63f122..683874057 100644 --- a/Tests/BrowserServicesKitTests/RemoteMessaging/Matchers/UserAttributeMatcherTests.swift +++ b/Tests/BrowserServicesKitTests/RemoteMessaging/Matchers/UserAttributeMatcherTests.swift @@ -53,8 +53,9 @@ class UserAttributeMatcherTests: XCTestCase { favoritesCount: 88, appTheme: "default", isWidgetInstalled: true, - isNetPWaitlistUser: true, - daysSinceNetPEnabled: 3) + daysSinceNetPEnabled: 3, + isPrivacyProEligibleUser: true, + isPrivacyProSubscriber: true) } override func tearDownWithError() throws { @@ -205,25 +206,35 @@ class UserAttributeMatcherTests: XCTestCase { .fail) } - // MARK: - Network Protection Waitlist + // MARK: - Privacy Pro - func testWhenIsNetPWaitlistUserMatchesThenReturnMatch() throws { - XCTAssertEqual(userAttributeMatcher.evaluate(matchingAttribute: IsNetPWaitlistUserMatchingAttribute(value: true, fallback: nil)), + func testWhenDaysSinceNetPEnabledMatchesThenReturnMatch() throws { + XCTAssertEqual(userAttributeMatcher.evaluate(matchingAttribute: DaysSinceNetPEnabledMatchingAttribute(min: 1, fallback: nil)), .match) } - func testWhenIsNetPWaitlistUserDoesNotMatchThenReturnFail() throws { - XCTAssertEqual(userAttributeMatcher.evaluate(matchingAttribute: IsNetPWaitlistUserMatchingAttribute(value: false, fallback: nil)), + func testWhenDaysSinceNetPEnabledDoesNotMatchThenReturnFail() throws { + XCTAssertEqual(userAttributeMatcher.evaluate(matchingAttribute: DaysSinceNetPEnabledMatchingAttribute(min: 7, fallback: nil)), .fail) } - func testWhenDaysSinceNetPEnabledMatchesThenReturnMatch() throws { - XCTAssertEqual(userAttributeMatcher.evaluate(matchingAttribute: DaysSinceNetPEnabledMatchingAttribute(min: 1, fallback: nil)), + func testWhenIsPrivacyProEligibleUserMatchesThenReturnMatch() throws { + XCTAssertEqual(userAttributeMatcher.evaluate(matchingAttribute: IsPrivacyProEligibleUserMatchingAttribute(value: true, fallback: nil)), .match) } - func testWhenDaysSinceNetPEnabledDoesNotMatchThenReturnFail() throws { - XCTAssertEqual(userAttributeMatcher.evaluate(matchingAttribute: DaysSinceNetPEnabledMatchingAttribute(min: 7, fallback: nil)), + func testWhenIsPrivacyProEligibleUserDoesNotMatchThenReturnFail() throws { + XCTAssertEqual(userAttributeMatcher.evaluate(matchingAttribute: IsPrivacyProEligibleUserMatchingAttribute(value: false, fallback: nil)), + .fail) + } + + func testWhenIsPrivacyProSubscriberMatchesThenReturnMatch() throws { + XCTAssertEqual(userAttributeMatcher.evaluate(matchingAttribute: IsPrivacyProSubscriberUserMatchingAttribute(value: true, fallback: nil)), + .match) + } + + func testWhenIsPrivacyProSubscriberDoesNotMatchThenReturnFail() throws { + XCTAssertEqual(userAttributeMatcher.evaluate(matchingAttribute: IsPrivacyProSubscriberUserMatchingAttribute(value: false, fallback: nil)), .fail) } diff --git a/Tests/BrowserServicesKitTests/RemoteMessaging/Mocks/MockRemoteMessageSurveyActionMapper.swift b/Tests/BrowserServicesKitTests/RemoteMessaging/Mocks/MockRemoteMessageSurveyActionMapper.swift new file mode 100644 index 000000000..07f9ec4d0 --- /dev/null +++ b/Tests/BrowserServicesKitTests/RemoteMessaging/Mocks/MockRemoteMessageSurveyActionMapper.swift @@ -0,0 +1,28 @@ +// +// MockRemoteMessageSurveyActionMapper.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 Foundation +import RemoteMessaging + +class MockRemoteMessageSurveyActionMapper: RemoteMessagingSurveyActionMapping { + + func add(parameters: [RemoteMessaging.RemoteMessagingSurveyActionParameter], to url: URL) -> URL { + return url + } + +} diff --git a/Tests/BrowserServicesKitTests/RemoteMessaging/RemoteMessagingConfigMatcherTests.swift b/Tests/BrowserServicesKitTests/RemoteMessaging/RemoteMessagingConfigMatcherTests.swift index 8d904ed91..ebaf2060c 100644 --- a/Tests/BrowserServicesKitTests/RemoteMessaging/RemoteMessagingConfigMatcherTests.swift +++ b/Tests/BrowserServicesKitTests/RemoteMessaging/RemoteMessagingConfigMatcherTests.swift @@ -43,9 +43,11 @@ class RemoteMessagingConfigMatcherTests: XCTestCase { favoritesCount: 0, appTheme: "light", isWidgetInstalled: false, - isNetPWaitlistUser: false, - daysSinceNetPEnabled: -1), + daysSinceNetPEnabled: -1, + isPrivacyProEligibleUser: false, + isPrivacyProSubscriber: false), percentileStore: MockRemoteMessagePercentileStore(), + surveyActionMapper: MockRemoteMessageSurveyActionMapper(), dismissedMessageIds: [] ) } @@ -127,9 +129,11 @@ class RemoteMessagingConfigMatcherTests: XCTestCase { favoritesCount: 0, appTheme: "light", isWidgetInstalled: false, - isNetPWaitlistUser: false, - daysSinceNetPEnabled: -1), + daysSinceNetPEnabled: -1, + isPrivacyProEligibleUser: false, + isPrivacyProSubscriber: false), percentileStore: MockRemoteMessagePercentileStore(), + surveyActionMapper: MockRemoteMessageSurveyActionMapper(), dismissedMessageIds: []) let remoteConfig = RemoteConfigModel(messages: [ @@ -221,9 +225,11 @@ class RemoteMessagingConfigMatcherTests: XCTestCase { favoritesCount: 0, appTheme: "light", isWidgetInstalled: false, - isNetPWaitlistUser: false, - daysSinceNetPEnabled: -1), + daysSinceNetPEnabled: -1, + isPrivacyProEligibleUser: false, + isPrivacyProSubscriber: false), percentileStore: MockRemoteMessagePercentileStore(), + surveyActionMapper: MockRemoteMessageSurveyActionMapper(), dismissedMessageIds: ["1"]) let remoteConfig = RemoteConfigModel(messages: [ @@ -258,9 +264,11 @@ class RemoteMessagingConfigMatcherTests: XCTestCase { favoritesCount: 0, appTheme: "light", isWidgetInstalled: false, - isNetPWaitlistUser: false, - daysSinceNetPEnabled: -1), + daysSinceNetPEnabled: -1, + isPrivacyProEligibleUser: false, + isPrivacyProSubscriber: false), percentileStore: MockRemoteMessagePercentileStore(), + surveyActionMapper: MockRemoteMessageSurveyActionMapper(), dismissedMessageIds: []) let remoteConfig = RemoteConfigModel(messages: [ @@ -291,9 +299,11 @@ class RemoteMessagingConfigMatcherTests: XCTestCase { favoritesCount: 0, appTheme: "light", isWidgetInstalled: false, - isNetPWaitlistUser: false, - daysSinceNetPEnabled: -1), + daysSinceNetPEnabled: -1, + isPrivacyProEligibleUser: false, + isPrivacyProSubscriber: false), percentileStore: percentileStore, + surveyActionMapper: MockRemoteMessageSurveyActionMapper(), dismissedMessageIds: []) let remoteConfig = RemoteConfigModel(messages: [ @@ -322,9 +332,11 @@ class RemoteMessagingConfigMatcherTests: XCTestCase { favoritesCount: 0, appTheme: "light", isWidgetInstalled: false, - isNetPWaitlistUser: false, - daysSinceNetPEnabled: -1), + daysSinceNetPEnabled: -1, + isPrivacyProEligibleUser: false, + isPrivacyProSubscriber: false), percentileStore: percentileStore, + surveyActionMapper: MockRemoteMessageSurveyActionMapper(), dismissedMessageIds: []) let remoteConfig = RemoteConfigModel(messages: [ @@ -353,9 +365,11 @@ class RemoteMessagingConfigMatcherTests: XCTestCase { favoritesCount: 0, appTheme: "light", isWidgetInstalled: false, - isNetPWaitlistUser: false, - daysSinceNetPEnabled: -1), + daysSinceNetPEnabled: -1, + isPrivacyProEligibleUser: false, + isPrivacyProSubscriber: false), percentileStore: percentileStore, + surveyActionMapper: MockRemoteMessageSurveyActionMapper(), dismissedMessageIds: []) let remoteConfig = RemoteConfigModel(messages: [ @@ -384,9 +398,11 @@ class RemoteMessagingConfigMatcherTests: XCTestCase { favoritesCount: 0, appTheme: "light", isWidgetInstalled: false, - isNetPWaitlistUser: false, - daysSinceNetPEnabled: -1), + daysSinceNetPEnabled: -1, + isPrivacyProEligibleUser: false, + isPrivacyProSubscriber: false), percentileStore: percentileStore, + surveyActionMapper: MockRemoteMessageSurveyActionMapper(), dismissedMessageIds: []) let remoteConfig = RemoteConfigModel(messages: [ @@ -435,9 +451,10 @@ class RemoteMessagingConfigMatcherTests: XCTestCase { func decodeAndMapJson(fileName: String) throws -> RemoteConfigModel { let validJson = data.fromJsonFile(fileName) let remoteMessagingConfig = try JSONDecoder().decode(RemoteMessageResponse.JsonRemoteMessagingConfig.self, from: validJson) + let surveyMapper = MockRemoteMessageSurveyActionMapper() XCTAssertNotNil(remoteMessagingConfig) - let config = JsonToRemoteConfigModelMapper.mapJson(remoteMessagingConfig: remoteMessagingConfig) + let config = JsonToRemoteConfigModelMapper.mapJson(remoteMessagingConfig: remoteMessagingConfig, surveyActionMapper: surveyMapper) XCTAssertNotNil(config) return config } diff --git a/Tests/BrowserServicesKitTests/RemoteMessaging/RemoteMessagingConfigProcessorTests.swift b/Tests/BrowserServicesKitTests/RemoteMessaging/RemoteMessagingConfigProcessorTests.swift index c42f273c0..bbfa2dd59 100644 --- a/Tests/BrowserServicesKitTests/RemoteMessaging/RemoteMessagingConfigProcessorTests.swift +++ b/Tests/BrowserServicesKitTests/RemoteMessaging/RemoteMessagingConfigProcessorTests.swift @@ -36,9 +36,11 @@ class RemoteMessagingConfigProcessorTests: XCTestCase { favoritesCount: 0, appTheme: "light", isWidgetInstalled: false, - isNetPWaitlistUser: false, - daysSinceNetPEnabled: -1), + daysSinceNetPEnabled: -1, + isPrivacyProEligibleUser: false, + isPrivacyProSubscriber: false), percentileStore: MockRemoteMessagePercentileStore(), + surveyActionMapper: MockRemoteMessageSurveyActionMapper(), dismissedMessageIds: [] ) @@ -65,9 +67,11 @@ class RemoteMessagingConfigProcessorTests: XCTestCase { favoritesCount: 0, appTheme: "light", isWidgetInstalled: false, - isNetPWaitlistUser: false, - daysSinceNetPEnabled: -1), + daysSinceNetPEnabled: -1, + isPrivacyProEligibleUser: false, + isPrivacyProSubscriber: false), percentileStore: MockRemoteMessagePercentileStore(), + surveyActionMapper: MockRemoteMessageSurveyActionMapper(), dismissedMessageIds: []) let processor = RemoteMessagingConfigProcessor(remoteMessagingConfigMatcher: remoteMessagingConfigMatcher) diff --git a/Tests/BrowserServicesKitTests/Resources/remote-messaging-config.json b/Tests/BrowserServicesKitTests/Resources/remote-messaging-config.json index e7a3d8fab..e75d3a15d 100644 --- a/Tests/BrowserServicesKitTests/Resources/remote-messaging-config.json +++ b/Tests/BrowserServicesKitTests/Resources/remote-messaging-config.json @@ -134,6 +134,23 @@ } } }, + { + "id": "9848E904-4345-09C8-FEAA-3B2C75DC285B", + "content": { + "messageType": "promo_single_action", + "titleText": "Survey Title", + "descriptionText": "Survey Description", + "placeholder": "VPNAnnounce", + "actionText": "Survey Action", + "action": { + "type": "survey", + "value": "https://duckduckgo.com/survey", + "additionalParameters": { + "queryParams": "atb;var;delta;osv;ddgv;da;unknown_param_which_is_not_supported" + } + } + } + }, { "id": "8E909844-C809-4543-AAFE-2C75DC285B3B", "content": { @@ -143,8 +160,11 @@ "placeholder": "VPNAnnounce", "actionText": "Survey Action", "action": { - "type": "survey_url", - "value": "https://duckduckgo.com/survey" + "type": "survey", + "value": "https://duckduckgo.com/survey", + "additionalParameters": { + "queryParams": "atb;var;delta;osv;ddgv;da" + } } }, "matchingRules": [ @@ -237,11 +257,14 @@ { "id": 8, "attributes": { - "isNetPWaitlistUser": { - "value": true - }, "daysSinceNetPEnabled": { "min": 5 + }, + "pproEligible": { + "value": true + }, + "pproSubscriber": { + "value": true } } }, From 7c235d29fc446436734612e81dd486b7c52aa577 Mon Sep 17 00:00:00 2001 From: Diego Rey Mendez Date: Fri, 24 May 2024 18:03:27 +0200 Subject: [PATCH 6/7] Removes VPN waitlist and beta code (#801) iOS: duckduckgo/iOS#2795 macOS: duckduckgo/macos-browser#2776 What kind of version bump will this require?: Minor Description Let's remove the VPN waitlist code. --- ...kProtectionCodeRedemptionCoordinator.swift | 26 +------ .../Networking/NetworkProtectionClient.swift | 10 --- .../UserDefaults+showMessaging.swift | 21 ------ .../NetworkProtectionClientTests.swift | 71 ++----------------- 4 files changed, 5 insertions(+), 123 deletions(-) diff --git a/Sources/NetworkProtection/FeatureActivation/NetworkProtectionCodeRedemptionCoordinator.swift b/Sources/NetworkProtection/FeatureActivation/NetworkProtectionCodeRedemptionCoordinator.swift index 8c1d00fbf..ca5f77bce 100644 --- a/Sources/NetworkProtection/FeatureActivation/NetworkProtectionCodeRedemptionCoordinator.swift +++ b/Sources/NetworkProtection/FeatureActivation/NetworkProtectionCodeRedemptionCoordinator.swift @@ -19,15 +19,8 @@ import Foundation import Common -public protocol NetworkProtectionCodeRedeeming { - - /// Redeems an invite code with the Network Protection backend and stores the resulting auth token - func redeem(_ code: String) async throws - -} - /// Coordinates calls to the backend and oAuth token storage -public final class NetworkProtectionCodeRedemptionCoordinator: NetworkProtectionCodeRedeeming { +public final class NetworkProtectionCodeRedemptionCoordinator { private let networkClient: NetworkProtectionClient private let tokenStore: NetworkProtectionTokenStore private let isManualCodeRedemptionFlow: Bool @@ -54,21 +47,4 @@ public final class NetworkProtectionCodeRedemptionCoordinator: NetworkProtection self.errorEvents = errorEvents } - public func redeem(_ code: String) async throws { - let result = await networkClient.redeem(inviteCode: code) - switch result { - case .success(let token): - try tokenStore.store(token) - - case .failure(let error): - if case .invalidInviteCode = error, isManualCodeRedemptionFlow { - // Deliberately ignore cases where invalid invite codes are entered into the redemption form - throw error - } else { - errorEvents.fire(error.networkProtectionError) - throw error - } - } - } - } diff --git a/Sources/NetworkProtection/Networking/NetworkProtectionClient.swift b/Sources/NetworkProtection/Networking/NetworkProtectionClient.swift index 2a86436a2..26626238d 100644 --- a/Sources/NetworkProtection/Networking/NetworkProtectionClient.swift +++ b/Sources/NetworkProtection/Networking/NetworkProtectionClient.swift @@ -19,7 +19,6 @@ import Foundation protocol NetworkProtectionClient { - func redeem(inviteCode: String) async -> Result func getLocations(authToken: String) async -> Result<[NetworkProtectionLocation], NetworkProtectionClientError> func getServers(authToken: String) async -> Result<[NetworkProtectionServer], NetworkProtectionClientError> func register(authToken: String, @@ -189,10 +188,6 @@ final class NetworkProtectionBackendClient: NetworkProtectionClient { endpointURL.appending("/register") } - var redeemURL: URL { - endpointURL.appending("/redeem") - } - private let decoder: JSONDecoder = { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withFullDate, .withFullTime, .withFractionalSeconds] @@ -381,11 +376,6 @@ final class NetworkProtectionBackendClient: NetworkProtectionClient { } } - public func redeem(inviteCode: String) async -> Result { - let requestBody = RedeemInviteCodeRequestBody(code: inviteCode) - return await retrieveAuthToken(requestBody: requestBody, endpoint: redeemURL) - } - private func retrieveAuthToken( requestBody: RequestBody, endpoint: URL diff --git a/Sources/NetworkProtection/Settings/Extensions/UserDefaults+showMessaging.swift b/Sources/NetworkProtection/Settings/Extensions/UserDefaults+showMessaging.swift index d905ccde2..86efb8930 100644 --- a/Sources/NetworkProtection/Settings/Extensions/UserDefaults+showMessaging.swift +++ b/Sources/NetworkProtection/Settings/Extensions/UserDefaults+showMessaging.swift @@ -85,24 +85,3 @@ 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/Tests/NetworkProtectionTests/NetworkProtectionClientTests.swift b/Tests/NetworkProtectionTests/NetworkProtectionClientTests.swift index ca87eee90..6b1011273 100644 --- a/Tests/NetworkProtectionTests/NetworkProtectionClientTests.swift +++ b/Tests/NetworkProtectionTests/NetworkProtectionClientTests.swift @@ -52,7 +52,7 @@ final class NetworkProtectionClientTests: XCTestCase { func testRegister401Response_ThrowsInvalidTokenError() async { let emptyData = "".data(using: .utf8)! - MockURLProtocol.stubs[client.redeemURL] = (response: HTTPURLResponse(url: client.registerKeyURL, statusCode: 401)!, + MockURLProtocol.stubs[client.registerKeyURL] = (response: HTTPURLResponse(url: client.registerKeyURL, statusCode: 401)!, .success(emptyData)) let body = RegisterKeyRequestBody(publicKey: .testData, serverSelection: .server(name: "MockServer")) @@ -68,7 +68,7 @@ final class NetworkProtectionClientTests: XCTestCase { func testGetServer401Response_ThrowsInvalidTokenError() async { let emptyData = "".data(using: .utf8)! - MockURLProtocol.stubs[client.redeemURL] = (response: HTTPURLResponse(url: client.serversURL, statusCode: 401)!, + MockURLProtocol.stubs[client.serversURL] = (response: HTTPURLResponse(url: client.serversURL, statusCode: 401)!, .success(emptyData)) let result = await client.getServers(authToken: "anAuthToken") @@ -79,74 +79,11 @@ final class NetworkProtectionClientTests: XCTestCase { } } - // MARK: redeem(inviteCode:) - - func testRedeemSuccess() async { - let token = "a6s7ad6ad76aasa7s6a" - let successData = redeemSuccessData(token: token) - MockURLProtocol.stubs[client.redeemURL] = (response: HTTPURLResponse(url: client.redeemURL, statusCode: 200)!, - .success(successData)) - - let result = await client.redeem(inviteCode: "DH76F8S") - - XCTAssertEqual(try? result.get(), token) - } - - func testRedeem400Response() async { - let emptyData = "".data(using: .utf8)! - MockURLProtocol.stubs[client.redeemURL] = (response: HTTPURLResponse(url: client.redeemURL, statusCode: 400)!, - .success(emptyData)) - - let result = await client.redeem(inviteCode: "DH76F8S") - - guard case .failure(let error) = result, case .invalidInviteCode = error else { - XCTFail("Expected an invalidInviteCode error to be thrown") - return - } - } - - func testRedeemNon200Or400Response() async { - let emptyData = "".data(using: .utf8)! - - for code in [401, 304, 500] { - MockURLProtocol.stubs[client.redeemURL] = (response: HTTPURLResponse(url: client.redeemURL, statusCode: code)!, - .success(emptyData)) - - let result = await client.redeem(inviteCode: "DH76F8S") - - guard case .failure(let error) = result, case .failedToRedeemInviteCode = error else { - XCTFail("Expected a failedToRedeemInviteCode error to be thrown") - return - } - } - } - - func testRedeemDecodeFailure() async { - let undecodableData = "sdfghj".data(using: .utf8)! - MockURLProtocol.stubs[client.redeemURL] = (response: HTTPURLResponse(url: client.redeemURL, statusCode: 200)!, - .success(undecodableData)) - - let result = await client.redeem(inviteCode: "DH76F8S") - - guard case .failure(let error) = result, case .failedToParseRedeemResponse = error else { - XCTFail("Expected a failedToRedeemInviteCode error to be thrown") - return - } - } - - private func redeemSuccessData(token: String) -> Data { - return """ - { - "token": "\(token)" - } - """.data(using: .utf8)! - } - // MARK: locations(authToken:) func testLocationsSuccess() async { let successData = TestData.mockLocations - MockURLProtocol.stubs[client.locationsURL] = (response: HTTPURLResponse(url: client.redeemURL, statusCode: 200)!, + MockURLProtocol.stubs[client.locationsURL] = (response: HTTPURLResponse(url: client.locationsURL, statusCode: 200)!, .success(successData)) let result = await client.getLocations(authToken: "DH76F8S") @@ -185,7 +122,7 @@ final class NetworkProtectionClientTests: XCTestCase { func testLocationsDecodeFailure() async { let undecodableData = "sdfghj".data(using: .utf8)! - MockURLProtocol.stubs[client.locationsURL] = (response: HTTPURLResponse(url: client.redeemURL, statusCode: 200)!, + MockURLProtocol.stubs[client.locationsURL] = (response: HTTPURLResponse(url: client.locationsURL, statusCode: 200)!, .success(undecodableData)) let result = await client.getLocations(authToken: "DH76F8S") From 0e0b17e14b7aca9a98284a454444be9cbf0e6c8d Mon Sep 17 00:00:00 2001 From: bwaresiak Date: Tue, 28 May 2024 17:04:36 +0200 Subject: [PATCH 7/7] Replace unowned reference with weak one (#832) Description: Replace unowned reference with weak one - it apparently break in tests. --- .../SubManagers/StorePurchaseManager.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Sources/Subscription/SubManagers/StorePurchaseManager.swift b/Sources/Subscription/SubManagers/StorePurchaseManager.swift index 2b5e0bf5b..d475a91df 100644 --- a/Sources/Subscription/SubManagers/StorePurchaseManager.swift +++ b/Sources/Subscription/SubManagers/StorePurchaseManager.swift @@ -253,7 +253,7 @@ public final class StorePurchaseManager: ObservableObject, StorePurchaseManaging private func observeTransactionUpdates() -> Task { - Task.detached { [unowned self] in + Task.detached { [weak self] in for await result in Transaction.updates { os_log(.info, log: .subscription, "[StorePurchaseManager] observeTransactionUpdates") @@ -261,18 +261,18 @@ public final class StorePurchaseManager: ObservableObject, StorePurchaseManaging await transaction.finish() } - await self.updatePurchasedProducts() + await self?.updatePurchasedProducts() } } } private func observeStorefrontChanges() -> Task { - Task.detached { [unowned self] in + Task.detached { [weak self] in for await result in Storefront.updates { os_log(.info, log: .subscription, "[StorePurchaseManager] observeStorefrontChanges: %s", result.countryCode) - await updatePurchasedProducts() - await updateAvailableProducts() + await self?.updatePurchasedProducts() + await self?.updateAvailableProducts() } } }