diff --git a/.swiftpm/xcode/package.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/.swiftpm/xcode/package.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
new file mode 100644
index 000000000..54782e32f
--- /dev/null
+++ b/.swiftpm/xcode/package.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
@@ -0,0 +1,8 @@
+
+
+
+
+ IDEWorkspaceSharedSettings_AutocreateContextsIfNeeded
+
+
+
diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/BrowserServicesKit-Package.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/BrowserServicesKit-Package.xcscheme
new file mode 100644
index 000000000..d3085d9c5
--- /dev/null
+++ b/.swiftpm/xcode/xcshareddata/xcschemes/BrowserServicesKit-Package.xcscheme
@@ -0,0 +1,645 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/SubscriptionTests.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/SubscriptionTests.xcscheme
new file mode 100644
index 000000000..d5d6b036a
--- /dev/null
+++ b/.swiftpm/xcode/xcshareddata/xcschemes/SubscriptionTests.xcscheme
@@ -0,0 +1,64 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Package.resolved b/Package.resolved
index 15038b08d..a36194371 100644
--- a/Package.resolved
+++ b/Package.resolved
@@ -14,8 +14,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/duckduckgo/content-scope-scripts",
"state" : {
- "revision" : "4689746e42b24c40c18896d697ea02b854e90d35",
- "version" : "5.21.0"
+ "revision" : "7ac68ae3bc052fa59adbc1ba8fd5cb5849a6bc99",
+ "version" : "5.25.0"
}
},
{
@@ -23,8 +23,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/duckduckgo/duckduckgo-autofill.git",
"state" : {
- "revision" : "10aeff1ec7f533d1705233a9b14f9393a699b1c0",
- "version" : "11.0.2"
+ "revision" : "2b81745565db09eee8c1cd44d38eefa1011a9f0a",
+ "version" : "12.0.1"
}
},
{
diff --git a/Package.swift b/Package.swift
index 36632e94a..739111e9b 100644
--- a/Package.swift
+++ b/Package.swift
@@ -39,12 +39,12 @@ let package = Package(
.library(name: "PixelKitTestingUtilities", targets: ["PixelKitTestingUtilities"]),
],
dependencies: [
- .package(url: "https://github.com/duckduckgo/duckduckgo-autofill.git", exact: "11.0.2"),
+ .package(url: "https://github.com/duckduckgo/duckduckgo-autofill.git", exact: "12.0.1"),
.package(url: "https://github.com/duckduckgo/GRDB.swift.git", exact: "2.3.0"),
.package(url: "https://github.com/duckduckgo/TrackerRadarKit", exact: "2.1.2"),
.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.21.0"),
+ .package(url: "https://github.com/duckduckgo/content-scope-scripts", exact: "5.25.0"),
.package(url: "https://github.com/duckduckgo/privacy-dashboard", exact: "4.1.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"),
diff --git a/Sources/Bookmarks/FaviconsFetcher/FaviconsFetchOperation.swift b/Sources/Bookmarks/FaviconsFetcher/FaviconsFetchOperation.swift
index 1ca0d5ed2..a8787d4a2 100644
--- a/Sources/Bookmarks/FaviconsFetcher/FaviconsFetchOperation.swift
+++ b/Sources/Bookmarks/FaviconsFetcher/FaviconsFetchOperation.swift
@@ -22,7 +22,7 @@ import Common
import CoreData
import Persistence
-final class FaviconsFetchOperation: Operation {
+final class FaviconsFetchOperation: Operation, @unchecked Sendable {
enum FaviconFetchError: Error {
case connectionError
diff --git a/Sources/BrowserServicesKit/Autofill/AutofillPixelReporter.swift b/Sources/BrowserServicesKit/Autofill/AutofillPixelReporter.swift
index 83d3d7aa9..a5a663860 100644
--- a/Sources/BrowserServicesKit/Autofill/AutofillPixelReporter.swift
+++ b/Sources/BrowserServicesKit/Autofill/AutofillPixelReporter.swift
@@ -29,6 +29,7 @@ public enum AutofillPixelEvent {
case autofillToggledOff
case autofillLoginsStacked
case autofillCreditCardsStacked
+ case autofillIdentitiesStacked
enum Parameter {
static let countBucket = "count_bucket"
@@ -148,6 +149,10 @@ public final class AutofillPixelReporter {
if let cardsCount = try? vault()?.creditCardsCount() {
eventMapping.fire(.autofillCreditCardsStacked, parameters: [AutofillPixelEvent.Parameter.countBucket: creditCardsBucketNameFrom(count: cardsCount)])
}
+
+ if let identitiesCount = try? vault()?.identitiesCount() {
+ eventMapping.fire(.autofillIdentitiesStacked, parameters: [AutofillPixelEvent.Parameter.countBucket: identitiesBucketNameFrom(count: identitiesCount)])
+ }
}
switch type {
@@ -247,6 +252,18 @@ public final class AutofillPixelReporter {
}
}
+ private func identitiesBucketNameFrom(count: Int) -> String {
+ if count == 0 {
+ return BucketName.none.rawValue
+ } else if count < 5 {
+ return BucketName.some.rawValue
+ } else if count < 12 {
+ return BucketName.many.rawValue
+ } else {
+ return BucketName.lots.rawValue
+ }
+ }
+
}
public extension NSNotification.Name {
diff --git a/Sources/BrowserServicesKit/PrivacyConfig/Features/PrivacyFeature.swift b/Sources/BrowserServicesKit/PrivacyConfig/Features/PrivacyFeature.swift
index 2419623b6..34445414d 100644
--- a/Sources/BrowserServicesKit/PrivacyConfig/Features/PrivacyFeature.swift
+++ b/Sources/BrowserServicesKit/PrivacyConfig/Features/PrivacyFeature.swift
@@ -47,6 +47,7 @@ public enum PrivacyFeature: String {
case privacyPro
case sslCertificates
case brokenSiteReportExperiment
+ case toggleReports
}
/// An abstraction to be implemented by any "subfeature" of a given `PrivacyConfiguration` feature.
@@ -92,14 +93,6 @@ public enum SyncSubfeature: String, PrivacySubfeature {
case level3AllowCreateAccount
}
-public enum PrivacyDashboardSubfeature: String, PrivacySubfeature {
-
- public var parent: PrivacyFeature { .privacyDashboard }
-
- case toggleReports
-
-}
-
public enum AutoconsentSubfeature: String, PrivacySubfeature {
public var parent: PrivacyFeature {
.autoconsent
@@ -127,4 +120,5 @@ public enum sslCertificatesSubfeature: String, PrivacySubfeature {
public enum DuckPlayerSubfeature: String, PrivacySubfeature {
public var parent: PrivacyFeature { .duckPlayer }
case pip
+ case autoplay
}
diff --git a/Sources/BrowserServicesKit/SecureVault/AutofillDatabaseProvider.swift b/Sources/BrowserServicesKit/SecureVault/AutofillDatabaseProvider.swift
index 70ec30ddc..d86104389 100644
--- a/Sources/BrowserServicesKit/SecureVault/AutofillDatabaseProvider.swift
+++ b/Sources/BrowserServicesKit/SecureVault/AutofillDatabaseProvider.swift
@@ -51,6 +51,7 @@ public protocol AutofillDatabaseProvider: SecureStorageDatabaseProvider {
func deleteNoteForNoteId(_ noteId: Int64) throws
func identities() throws -> [SecureVaultModels.Identity]
+ func identitiesCount() throws -> Int
func identityForIdentityId(_ identityId: Int64) throws -> SecureVaultModels.Identity?
@discardableResult
func storeIdentity(_ identity: SecureVaultModels.Identity) throws -> Int64
@@ -503,6 +504,13 @@ public final class DefaultAutofillDatabaseProvider: GRDBSecureStorageDatabasePro
}
}
+ public func identitiesCount() throws -> Int {
+ let count = try db.read {
+ try SecureVaultModels.Identity.fetchCount($0)
+ }
+ return count
+ }
+
public func identityForIdentityId(_ identityId: Int64) throws -> SecureVaultModels.Identity? {
try db.read {
return try SecureVaultModels.Identity.fetchOne($0, sql: """
diff --git a/Sources/BrowserServicesKit/SecureVault/AutofillSecureVault.swift b/Sources/BrowserServicesKit/SecureVault/AutofillSecureVault.swift
index 6c7f49c56..be81e062f 100644
--- a/Sources/BrowserServicesKit/SecureVault/AutofillSecureVault.swift
+++ b/Sources/BrowserServicesKit/SecureVault/AutofillSecureVault.swift
@@ -80,6 +80,7 @@ public protocol AutofillSecureVault: SecureVault {
func deleteNoteFor(noteId: Int64) throws
func identities() throws -> [SecureVaultModels.Identity]
+ func identitiesCount() throws -> Int
func identityFor(id: Int64) throws -> SecureVaultModels.Identity?
func existingIdentityForAutofill(matching proposedIdentity: SecureVaultModels.Identity) throws -> SecureVaultModels.Identity?
@discardableResult
@@ -480,6 +481,12 @@ public class DefaultAutofillSecureVault: AutofillSe
}
}
+ public func identitiesCount() throws -> Int {
+ return try executeThrowingDatabaseOperation {
+ return try self.providers.database.identitiesCount()
+ }
+ }
+
public func identityFor(id: Int64) throws -> SecureVaultModels.Identity? {
return try executeThrowingDatabaseOperation {
return try self.providers.database.identityForIdentityId(id)
diff --git a/Sources/BrowserServicesKit/SmarterEncryption/HTTPSUpgrade.swift b/Sources/BrowserServicesKit/SmarterEncryption/HTTPSUpgrade.swift
index 6d7fad50d..171fa90e8 100644
--- a/Sources/BrowserServicesKit/SmarterEncryption/HTTPSUpgrade.swift
+++ b/Sources/BrowserServicesKit/SmarterEncryption/HTTPSUpgrade.swift
@@ -33,12 +33,12 @@ public enum HTTPSUpgradeError: Error {
public actor HTTPSUpgrade {
private var dataReloadTask: Task?
- private let store: HTTPSUpgradeStore
- private let privacyManager: PrivacyConfigurationManaging
+ private nonisolated let store: HTTPSUpgradeStore
+ private nonisolated let privacyManager: PrivacyConfigurationManaging
private var bloomFilter: BloomFilter?
- private let getLog: () -> OSLog
+ private nonisolated let getLog: () -> OSLog
nonisolated private var log: OSLog {
getLog()
}
diff --git a/Sources/BrowserServicesKit/SubscriptionFeatureAvailability.swift b/Sources/BrowserServicesKit/Subscription/SubscriptionFeatureAvailability.swift
similarity index 100%
rename from Sources/BrowserServicesKit/SubscriptionFeatureAvailability.swift
rename to Sources/BrowserServicesKit/Subscription/SubscriptionFeatureAvailability.swift
diff --git a/Sources/Common/Concurrency/MainActorExtension.swift b/Sources/Common/Concurrency/MainActorExtension.swift
new file mode 100644
index 000000000..cccc5a0a6
--- /dev/null
+++ b/Sources/Common/Concurrency/MainActorExtension.swift
@@ -0,0 +1,45 @@
+//
+// MainActorExtension.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
+
+#if swift(<5.10)
+private protocol MainActorPerformer {
+ func perform(_ operation: @MainActor () throws -> T) rethrows -> T
+}
+private struct OnMainActor: MainActorPerformer {
+ private init() {}
+ static func instance() -> MainActorPerformer { OnMainActor() }
+
+ @MainActor(unsafe)
+ func perform(_ operation: @MainActor () throws -> T) rethrows -> T {
+ try operation()
+ }
+}
+public extension MainActor {
+ static func assumeIsolated(_ operation: @MainActor () throws -> T) rethrows -> T {
+ if #available(macOS 14.0, iOS 17.0, *) {
+ return try assumeIsolated(operation, file: #fileID, line: #line)
+ }
+ dispatchPrecondition(condition: .onQueue(.main))
+ return try OnMainActor.instance().perform(operation)
+ }
+}
+#else
+ #warning("This needs to be removed as it‘s no longer necessary.")
+#endif
diff --git a/Sources/Common/Extensions/DateExtension.swift b/Sources/Common/Extensions/DateExtension.swift
index 7bcba129c..f2463a8b0 100644
--- a/Sources/Common/Extensions/DateExtension.swift
+++ b/Sources/Common/Extensions/DateExtension.swift
@@ -37,6 +37,14 @@ public extension Date {
return Calendar.current.date(byAdding: .month, value: -1, to: Date())!
}
+ static var yearAgo: Date! {
+ return Calendar.current.date(byAdding: .year, value: -1, to: Date())!
+ }
+
+ static var aYearFromNow: Date! {
+ return Calendar.current.date(byAdding: .year, value: 1, to: Date())!
+ }
+
static func daysAgo(_ days: Int) -> Date! {
return Calendar.current.date(byAdding: .day, value: -days, to: Date())!
}
diff --git a/Sources/Common/Extensions/StringExtension.swift b/Sources/Common/Extensions/StringExtension.swift
index f9e375b2a..07390d0d3 100644
--- a/Sources/Common/Extensions/StringExtension.swift
+++ b/Sources/Common/Extensions/StringExtension.swift
@@ -110,6 +110,16 @@ public extension String {
return false
}
+ var isValidIpv4Host: Bool {
+ guard let toIPv4Host, !toIPv4Host.isEmpty else { return false }
+ return true
+ }
+
+ var toIPv4Host: String? {
+ guard let ipv4 = IPv4Address(self) else { return nil }
+ return [UInt8](ipv4.rawValue).map { String($0) }.joined(separator: ".")
+ }
+
// MARK: Regex
func matches(_ regex: NSRegularExpression) -> Bool {
diff --git a/Sources/Common/Extensions/URLExtension.swift b/Sources/Common/Extensions/URLExtension.swift
index 0942d42ae..762532564 100644
--- a/Sources/Common/Extensions/URLExtension.swift
+++ b/Sources/Common/Extensions/URLExtension.swift
@@ -79,7 +79,7 @@ extension URL {
return host == domain || host.hasSuffix(".\(domain)")
}
- public struct NavigationalScheme: RawRepresentable, Hashable {
+ public struct NavigationalScheme: RawRepresentable, Hashable, Sendable {
public let rawValue: String
public static let separator = "://"
diff --git a/Sources/Common/Extensions/WKWebViewExtension.swift b/Sources/Common/Extensions/WKWebViewExtension.swift
new file mode 100644
index 000000000..d683aba38
--- /dev/null
+++ b/Sources/Common/Extensions/WKWebViewExtension.swift
@@ -0,0 +1,48 @@
+//
+// WKWebViewExtension.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 WebKit
+
+public extension WKWebView {
+
+ /// Calling this method is equivalent to calling `evaluateJavaScript:inFrame:inContentWorld:completionHandler:` with:
+ /// - A `frame` value of `nil` to represent the main frame
+ /// - A `contentWorld` value of `WKContentWorld.pageWorld`
+ @MainActor func evaluateJavaScript(_ script: String) async throws -> T? {
+ try await withUnsafeThrowingContinuation { c in
+ evaluateJavaScript(script) { result, error in
+ if let error {
+ c.resume(with: .failure(error))
+ } else {
+ c.resume(with: .success(result as? T))
+ }
+ }
+ }
+ }
+
+ // This is meant to cause the `Ambiguous use` error, because async `evaluateJavaScript(_) -> Any`
+ // call will crash when its result is `nil`.
+ // Use typed `try await evaluateJavaScript(script) as Void?` (or other type you need),
+ // or even better `try await evaluateJavaScript(script, in: nil, in: .page|.defaultClient) -> Any?` (available in macOS 12/iOS 15)
+ @available(*, deprecated, message: "Use `try await evaluateJavaScript(script) as Void?` instead.")
+ @MainActor func evaluateJavaScript(_ script: String) async throws {
+ assertionFailure("Use `try await evaluateJavaScript(script) as Void?` instead of `try await evaluateJavaScript(script)` as it will crash in runtime")
+ try await evaluateJavaScript(script) as Void?
+ }
+
+}
diff --git a/Sources/DDGSync/internal/SyncOperation.swift b/Sources/DDGSync/internal/SyncOperation.swift
index ee1fa9669..9abe6d3dd 100644
--- a/Sources/DDGSync/internal/SyncOperation.swift
+++ b/Sources/DDGSync/internal/SyncOperation.swift
@@ -21,7 +21,7 @@ import Combine
import Common
import Gzip
-final class SyncOperation: Operation {
+final class SyncOperation: Operation, @unchecked Sendable {
let dataProviders: [DataProviding]
let storage: SecureStoring
diff --git a/Sources/Navigation/DistributedNavigationDelegate.swift b/Sources/Navigation/DistributedNavigationDelegate.swift
index 3e19e3db1..de7b40daf 100644
--- a/Sources/Navigation/DistributedNavigationDelegate.swift
+++ b/Sources/Navigation/DistributedNavigationDelegate.swift
@@ -881,21 +881,21 @@ extension DistributedNavigationDelegate: WKNavigationDelegate {
}
} else {
- // don‘t mark extra Session State Pop navigations as `current` when there‘s a `current` Anchor navigation stored in `startedNavigation`
- let isCurrent = if let startedNavigation {
- !(startedNavigation.navigationAction.navigationType.isSameDocumentNavigation && startedNavigation.isCurrent)
- } else {
- true
- }
+ let shouldBecomeCurrent = {
+ guard let startedNavigation else { return true } // no current navigation, make the same-doc navigation current
+ guard startedNavigation.navigationAction.navigationType.isSameDocumentNavigation else { return false } // don‘t intrude into current non-same-doc navigation
+ // don‘t mark extra Session State Pop navigations as `current` when there‘s a `current` same-doc Anchor navigation stored in `startedNavigation`
+ return !startedNavigation.isCurrent
+ }()
- navigation = Navigation(identity: NavigationIdentity(wkNavigation), responders: responders, state: .expected(nil), isCurrent: isCurrent)
+ navigation = Navigation(identity: NavigationIdentity(wkNavigation), responders: responders, state: .expected(nil), isCurrent: shouldBecomeCurrent)
let request = wkNavigation?.request ?? URLRequest(url: webView.url ?? .empty)
let navigationAction = NavigationAction(request: request, navigationType: .sameDocumentNavigation(navigationType), currentHistoryItemIdentity: currentHistoryItemIdentity, redirectHistory: nil, isUserInitiated: wkNavigation?.isUserInitiated ?? false, sourceFrame: .mainFrame(for: webView), targetFrame: .mainFrame(for: webView), shouldDownload: false, mainFrameNavigation: navigation)
navigation.navigationActionReceived(navigationAction)
- os_log("new same-doc navigation(.%d): %s (%s): %s, isCurrent: %d", log: log, type: .debug, wkNavigationType, wkNavigation.debugDescription, navigation.debugDescription, navigationAction.debugDescription, isCurrent ? 1 : 0)
+ os_log("new same-doc navigation(.%d): %s (%s): %s, isCurrent: %d", log: log, type: .debug, wkNavigationType, wkNavigation.debugDescription, navigation.debugDescription, navigationAction.debugDescription, shouldBecomeCurrent ? 1 : 0)
// store `current` navigations in `startedNavigation` to get `currentNavigation` published
- if isCurrent {
+ if shouldBecomeCurrent {
self.startedNavigation = navigation
}
// mark Navigation as finished as we‘re in __did__SameDocumentNavigation
diff --git a/Sources/Navigation/Navigation.swift b/Sources/Navigation/Navigation.swift
index cc53eaa45..ab8d8dcf5 100644
--- a/Sources/Navigation/Navigation.swift
+++ b/Sources/Navigation/Navigation.swift
@@ -91,6 +91,7 @@ public final class Navigation {
}
public protocol NavigationProtocol: AnyObject {
+ @MainActor
var navigationResponders: ResponderChain { get set }
}
@@ -386,8 +387,14 @@ extension Navigation {
}
extension Navigation: CustomDebugStringConvertible {
- public var debugDescription: String {
- "<\(identity) #\(navigationAction.identifier): url:\(url.absoluteString) state:\(state)\(isCommitted ? "(committed)" : "") type:\(navigationActions.last?.navigationType.debugDescription ?? "")\(isCurrent ? "" : " non-current")>"
+ public nonisolated var debugDescription: String {
+ guard Thread.isMainThread else {
+ assertionFailure("Accessing Navigation from background thread")
+ return ""
+ }
+ return MainActor.assumeIsolated {
+ "<\(identity) #\(navigationAction.identifier): url:\(url.absoluteString) state:\(state)\(isCommitted ? "(committed)" : "") type:\(navigationActions.last?.navigationType.debugDescription ?? "")\(isCurrent ? "" : " non-current")>"
+ }
}
}
diff --git a/Sources/Navigation/NavigationAction.swift b/Sources/Navigation/NavigationAction.swift
index 68b207057..49e88534e 100644
--- a/Sources/Navigation/NavigationAction.swift
+++ b/Sources/Navigation/NavigationAction.swift
@@ -20,7 +20,7 @@ import Common
import Foundation
import WebKit
-public struct MainFrame {
+public struct MainFrame: Sendable {
fileprivate init() {}
}
@@ -239,11 +239,11 @@ public struct NavigationPreferences: Equatable {
}
-public enum NavigationActionPolicy {
+public enum NavigationActionPolicy: Sendable {
case allow
case cancel
case download
- case redirect(MainFrame, (Navigator) -> Void)
+ case redirect(MainFrame, @Sendable @MainActor (Navigator) -> Void)
}
extension NavigationActionPolicy? {
diff --git a/Sources/Navigation/NavigationResponse.swift b/Sources/Navigation/NavigationResponse.swift
index 29a055c26..f749aa18f 100644
--- a/Sources/Navigation/NavigationResponse.swift
+++ b/Sources/Navigation/NavigationResponse.swift
@@ -50,7 +50,7 @@ public struct NavigationResponse {
public extension NavigationResponse {
var url: URL {
- response.url!
+ response.url ?? .empty
}
var httpResponse: HTTPURLResponse? {
diff --git a/Sources/Navigation/Navigator.swift b/Sources/Navigation/Navigator.swift
index 04147968b..f03e7ffc4 100644
--- a/Sources/Navigation/Navigator.swift
+++ b/Sources/Navigation/Navigator.swift
@@ -16,6 +16,7 @@
// limitations under the License.
//
+import Common
import Foundation
import WebKit
@@ -149,8 +150,14 @@ public final class ExpectedNavigation {
}
extension ExpectedNavigation: NavigationProtocol {}
extension ExpectedNavigation: CustomDebugStringConvertible {
- public var debugDescription: String {
- ""
+ public nonisolated var debugDescription: String {
+ guard Thread.isMainThread else {
+ assertionFailure("Accessing ExpectedNavigation from background thread")
+ return ""
+ }
+ return MainActor.assumeIsolated {
+ ""
+ }
}
}
diff --git a/Sources/NetworkProtection/Diagnostics/NetworkProtectionConnectionBandwidthAnalyzer.swift b/Sources/NetworkProtection/Diagnostics/NetworkProtectionConnectionBandwidthAnalyzer.swift
index 31de5820c..e28611f08 100644
--- a/Sources/NetworkProtection/Diagnostics/NetworkProtectionConnectionBandwidthAnalyzer.swift
+++ b/Sources/NetworkProtection/Diagnostics/NetworkProtectionConnectionBandwidthAnalyzer.swift
@@ -90,7 +90,6 @@ final class NetworkProtectionConnectionBandwidthAnalyzer {
os_log("Bytes per second in last time-interval: (rx: %{public}@, tx: %{public}@)",
log: .networkProtectionBandwidthAnalysis,
- type: .info,
String(describing: rx), String(describing: tx))
idle = UInt64(rx) < Self.rxThreshold && UInt64(tx) < Self.txThreshold
@@ -103,7 +102,7 @@ final class NetworkProtectionConnectionBandwidthAnalyzer {
/// Useful when servers are swapped
///
func reset() {
- os_log("Bandwidth analyzer reset", log: .networkProtectionBandwidthAnalysis, type: .info)
+ os_log("Bandwidth analyzer reset", log: .networkProtectionBandwidthAnalysis)
entries.removeAll()
}
diff --git a/Sources/NetworkProtection/Diagnostics/NetworkProtectionConnectionTester.swift b/Sources/NetworkProtection/Diagnostics/NetworkProtectionConnectionTester.swift
index be98ebf95..ec09ba5ba 100644
--- a/Sources/NetworkProtection/Diagnostics/NetworkProtectionConnectionTester.swift
+++ b/Sources/NetworkProtection/Diagnostics/NetworkProtectionConnectionTester.swift
@@ -79,7 +79,7 @@ final class NetworkProtectionConnectionTester {
// MARK: - Logging
- private let log: OSLog
+ private nonisolated let log: OSLog
// MARK: - Test result handling
@@ -222,7 +222,7 @@ final class NetworkProtectionConnectionTester {
// After completing the connection tests we check if the tester is still supposed to be running
// to avoid giving results when it should not be running.
guard isRunning else {
- os_log("Tester skipped returning results as it was stopped while running the tests", log: log, type: .info)
+ os_log("Tester skipped returning results as it was stopped while running the tests", log: log)
return
}
diff --git a/Sources/NetworkProtection/Diagnostics/NetworkProtectionTunnelFailureMonitor.swift b/Sources/NetworkProtection/Diagnostics/NetworkProtectionTunnelFailureMonitor.swift
index 58eedab19..037361c1c 100644
--- a/Sources/NetworkProtection/Diagnostics/NetworkProtectionTunnelFailureMonitor.swift
+++ b/Sources/NetworkProtection/Diagnostics/NetworkProtectionTunnelFailureMonitor.swift
@@ -108,7 +108,7 @@ public actor NetworkProtectionTunnelFailureMonitor {
guard firstCheckSkipped else {
// Avoid running the first tunnel failure check after startup to avoid reading the first handshake after sleep, which will almost always
// be out of date. In normal operation, the first check will frequently be 0 as WireGuard hasn't had the chance to handshake yet.
- os_log("⚫️ Skipping first tunnel failure check", log: .networkProtectionTunnelFailureMonitorLog, type: .debug)
+ os_log("⚫️ Skipping first tunnel failure check", log: .networkProtectionTunnelFailureMonitorLog)
firstCheckSkipped = true
return
}
@@ -116,23 +116,23 @@ public actor NetworkProtectionTunnelFailureMonitor {
let mostRecentHandshake = (try? await handshakeReporter.getMostRecentHandshake()) ?? 0
guard mostRecentHandshake > 0 else {
- os_log("⚫️ Got handshake timestamp at or below 0, skipping check", log: .networkProtectionTunnelFailureMonitorLog, type: .debug)
+ os_log("⚫️ Got handshake timestamp at or below 0, skipping check", log: .networkProtectionTunnelFailureMonitorLog)
return
}
let difference = Date().timeIntervalSince1970 - mostRecentHandshake
- os_log("⚫️ Last handshake: %{public}f seconds ago", log: .networkProtectionTunnelFailureMonitorLog, type: .debug, difference)
+ os_log("⚫️ Last handshake: %{public}f seconds ago", log: .networkProtectionTunnelFailureMonitorLog, difference)
if difference > Result.failureDetected.threshold, isConnected {
if failureReported {
- os_log("⚫️ Tunnel failure already reported", log: .networkProtectionTunnelFailureMonitorLog, type: .debug)
+ os_log("⚫️ Tunnel failure already reported", log: .networkProtectionTunnelFailureMonitorLog)
} else {
- os_log("⚫️ Tunnel failure reported", log: .networkProtectionTunnelFailureMonitorLog, type: .debug)
+ os_log("⚫️ Tunnel failure reported", log: .networkProtectionTunnelFailureMonitorLog)
callback(.failureDetected)
failureReported = true
}
} else if difference <= Result.failureRecovered.threshold, failureReported {
- os_log("⚫️ Tunnel failure recovery", log: .networkProtectionTunnelFailureMonitorLog, type: .debug)
+ os_log("⚫️ Tunnel recovered from failure", log: .networkProtectionTunnelFailureMonitorLog)
callback(.failureRecovered)
failureReported = false
}
diff --git a/Sources/NetworkProtection/ExtensionMessage/ExtensionRequest.swift b/Sources/NetworkProtection/ExtensionMessage/ExtensionRequest.swift
index 03f59244c..693cde61a 100644
--- a/Sources/NetworkProtection/ExtensionMessage/ExtensionRequest.swift
+++ b/Sources/NetworkProtection/ExtensionMessage/ExtensionRequest.swift
@@ -25,6 +25,7 @@ public enum VPNCommand: Codable {
case sendTestNotification
case uninstallVPN
case disableConnectOnDemandAndShutDown
+ case quitAgent
}
public enum ExtensionRequest: Codable {
diff --git a/Sources/NetworkProtection/KeyManagement/NetworkProtectionKeychainStore.swift b/Sources/NetworkProtection/KeyManagement/NetworkProtectionKeychainStore.swift
index 384a87e5a..88ecb495d 100644
--- a/Sources/NetworkProtection/KeyManagement/NetworkProtectionKeychainStore.swift
+++ b/Sources/NetworkProtection/KeyManagement/NetworkProtectionKeychainStore.swift
@@ -72,7 +72,7 @@ final class NetworkProtectionKeychainStore {
case errSecItemNotFound:
return nil
default:
- os_log("🔵 SecItemCopyMatching status %{public}@", type: .error, String(describing: status))
+ os_log("🔴 SecItemCopyMatching status %{public}@", type: .error, String(describing: status))
throw NetworkProtectionKeychainStoreError.keychainReadError(field: name, status: status)
}
}
diff --git a/Sources/NetworkProtection/Logging/Logging.swift b/Sources/NetworkProtection/Logging/Logging.swift
index 240599f1d..2cfb99633 100644
--- a/Sources/NetworkProtection/Logging/Logging.swift
+++ b/Sources/NetworkProtection/Logging/Logging.swift
@@ -41,10 +41,6 @@ extension OSLog {
Logging.networkProtectionTunnelFailureMonitorLoggingEnabled ? Logging.networkProtectionTunnelFailureMonitor : .disabled
}
- public static var networkProtectionServerFailureRecoveryLog: OSLog {
- Logging.networkProtectionServerFailureRecoveryLoggingEnabled ? Logging.networkProtectionServerFailureRecovery : .disabled
- }
-
public static var networkProtectionConnectionTesterLog: OSLog {
Logging.networkProtectionConnectionTesterLoggingEnabled ? Logging.networkProtectionConnectionTesterLog : .disabled
}
@@ -84,7 +80,7 @@ extension OSLog {
struct Logging {
- static let subsystem = "com.duckduckgo.macos.browser.network-protection"
+ static let subsystem = Bundle.main.bundleIdentifier!
fileprivate static let networkProtectionLoggingEnabled = true
fileprivate static let networkProtection: OSLog = OSLog(subsystem: subsystem, category: "Network Protection")
diff --git a/Sources/NetworkProtection/NetworkProtectionDeviceManager.swift b/Sources/NetworkProtection/NetworkProtectionDeviceManager.swift
index f899f419f..d8df572c1 100644
--- a/Sources/NetworkProtection/NetworkProtectionDeviceManager.swift
+++ b/Sources/NetworkProtection/NetworkProtectionDeviceManager.swift
@@ -43,12 +43,30 @@ public enum NetworkProtectionServerSelectionMethod: CustomDebugStringConvertible
case failureRecovery(serverName: String)
}
+public enum NetworkProtectionDNSSettings: Codable, Equatable, CustomStringConvertible {
+ case `default`
+ case custom([String])
+
+ public var usesCustomDNS: Bool {
+ guard case .custom(let servers) = self, !servers.isEmpty else { return false }
+ return true
+ }
+
+ public var description: String {
+ switch self {
+ case .default: return "DuckDuckGo"
+ case .custom(let servers): return servers.joined(separator: ", ")
+ }
+ }
+}
+
public protocol NetworkProtectionDeviceManagement {
typealias GenerateTunnelConfigurationResult = (tunnelConfiguration: TunnelConfiguration, server: NetworkProtectionServer)
func generateTunnelConfiguration(selectionMethod: NetworkProtectionServerSelectionMethod,
includedRoutes: [IPAddressRange],
excludedRoutes: [IPAddressRange],
+ dnsSettings: NetworkProtectionDNSSettings,
isKillSwitchEnabled: Bool,
regenerateKey: Bool) async throws -> GenerateTunnelConfigurationResult
@@ -118,6 +136,7 @@ public actor NetworkProtectionDeviceManager: NetworkProtectionDeviceManagement {
public func generateTunnelConfiguration(selectionMethod: NetworkProtectionServerSelectionMethod,
includedRoutes: [IPAddressRange],
excludedRoutes: [IPAddressRange],
+ dnsSettings: NetworkProtectionDNSSettings,
isKillSwitchEnabled: Bool,
regenerateKey: Bool) async throws -> GenerateTunnelConfigurationResult {
var keyPair: KeyPair
@@ -156,6 +175,7 @@ public actor NetworkProtectionDeviceManager: NetworkProtectionDeviceManagement {
server: selectedServer,
includedRoutes: includedRoutes,
excludedRoutes: excludedRoutes,
+ dnsSettings: dnsSettings,
isKillSwitchEnabled: isKillSwitchEnabled)
return (configuration, selectedServer)
} catch let error as NetworkProtectionError {
@@ -246,6 +266,7 @@ public actor NetworkProtectionDeviceManager: NetworkProtectionDeviceManagement {
server: NetworkProtectionServer,
includedRoutes: [IPAddressRange],
excludedRoutes: [IPAddressRange],
+ dnsSettings: NetworkProtectionDNSSettings,
isKillSwitchEnabled: Bool) throws -> TunnelConfiguration {
guard let allowedIPs = server.allowedIPs else {
@@ -266,11 +287,21 @@ public actor NetworkProtectionDeviceManager: NetworkProtectionDeviceManagement {
throw NetworkProtectionError.couldNotGetInterfaceAddressRange
}
+ let dns: [DNSServer]
+ switch dnsSettings {
+ case .default:
+ dns = [DNSServer(address: server.serverInfo.internalIP)]
+ case .custom(let servers):
+ dns = servers
+ .compactMap { IPv4Address($0) }
+ .map { DNSServer(address: $0) }
+ }
+
let interface = interfaceConfiguration(privateKey: interfacePrivateKey,
addressRange: interfaceAddressRange,
includedRoutes: includedRoutes,
excludedRoutes: excludedRoutes,
- dns: [DNSServer(address: server.serverInfo.internalIP)],
+ dns: dns,
isKillSwitchEnabled: isKillSwitchEnabled)
return TunnelConfiguration(name: "DuckDuckGo VPN", interface: interface, peers: [peerConfiguration])
diff --git a/Sources/NetworkProtection/NetworkProtectionOptionKey.swift b/Sources/NetworkProtection/NetworkProtectionOptionKey.swift
index 3c0316b37..54a15a7f8 100644
--- a/Sources/NetworkProtection/NetworkProtectionOptionKey.swift
+++ b/Sources/NetworkProtection/NetworkProtectionOptionKey.swift
@@ -23,6 +23,7 @@ public enum NetworkProtectionOptionKey {
public static let selectedEnvironment = "selectedEnvironment"
public static let selectedServer = "selectedServer"
public static let selectedLocation = "selectedLocation"
+ public static let dnsSettings = "dnsSettings"
public static let authToken = "authToken"
public static let isOnDemand = "is-on-demand"
public static let activationAttemptId = "activationAttemptId"
diff --git a/Sources/NetworkProtection/PacketTunnelProvider.swift b/Sources/NetworkProtection/PacketTunnelProvider.swift
index 886872394..730c044e7 100644
--- a/Sources/NetworkProtection/PacketTunnelProvider.swift
+++ b/Sources/NetworkProtection/PacketTunnelProvider.swift
@@ -37,6 +37,7 @@ open class PacketTunnelProvider: NEPacketTunnelProvider {
case tunnelStopAttempt(_ step: TunnelStopAttemptStep)
case tunnelUpdateAttempt(_ step: TunnelUpdateAttemptStep)
case tunnelWakeAttempt(_ step: TunnelWakeAttemptStep)
+ case tunnelStartOnDemandWithoutAccessToken
case reportTunnelFailure(result: NetworkProtectionTunnelFailureMonitor.Result)
case reportLatency(result: NetworkProtectionLatencyMonitor.Result)
case rekeyAttempt(_ step: RekeyAttemptStep)
@@ -125,9 +126,9 @@ open class PacketTunnelProvider: NEPacketTunnelProvider {
private lazy var adapter: WireGuardAdapter = {
WireGuardAdapter(with: self) { logLevel, message in
if logLevel == .error {
- os_log("🔵 Received error from adapter: %{public}@", log: .networkProtection, type: .error, message)
+ os_log("🔴 Received error from adapter: %{public}@", log: .networkProtection, type: .error, message)
} else {
- os_log("🔵 Received message from adapter: %{public}@", log: .networkProtection, message)
+ os_log("Received message from adapter: %{public}@", log: .networkProtection, message)
}
}
}()
@@ -244,14 +245,15 @@ open class PacketTunnelProvider: NEPacketTunnelProvider {
return
}
- os_log("Rekeying...", log: .networkProtectionKeyManagement)
providerEvents.fire(.rekeyAttempt(.begin))
do {
- try await updateTunnelConfiguration(reassert: false, regenerateKey: true)
+ try await updateTunnelConfiguration(
+ updateMethod: .selectServer(currentServerSelectionMethod),
+ reassert: false,
+ regenerateKey: true)
providerEvents.fire(.rekeyAttempt(.success))
} catch {
- os_log("Rekey attempt failed. This is not an error if you're using debug Key Management options: %{public}@", log: .networkProtectionKeyManagement, type: .error, String(describing: error))
providerEvents.fire(.rekeyAttempt(.failure(error)))
throw error
}
@@ -439,6 +441,7 @@ open class PacketTunnelProvider: NEPacketTunnelProvider {
loadSelectedEnvironment(from: options)
loadSelectedServer(from: options)
loadSelectedLocation(from: options)
+ loadDNSSettings(from: options)
loadTesterEnabled(from: options)
#if os(macOS)
try loadAuthToken(from: options)
@@ -486,8 +489,8 @@ open class PacketTunnelProvider: NEPacketTunnelProvider {
private func loadSelectedLocation(from options: StartupOptions) {
switch options.selectedLocation {
- case .set(let selectedServer):
- settings.selectedLocation = selectedServer
+ case .set(let selectedLocation):
+ settings.selectedLocation = selectedLocation
case .useExisting:
break
case .reset:
@@ -495,6 +498,17 @@ open class PacketTunnelProvider: NEPacketTunnelProvider {
}
}
+ private func loadDNSSettings(from options: StartupOptions) {
+ switch options.dnsSettings {
+ case .set(let dnsSettings):
+ settings.dnsSettings = dnsSettings
+ case .useExisting:
+ break
+ case .reset:
+ settings.dnsSettings = .default
+ }
+ }
+
private func loadTesterEnabled(from options: StartupOptions) {
switch options.enableTester {
case .set(let value):
@@ -578,6 +592,10 @@ open class PacketTunnelProvider: NEPacketTunnelProvider {
do {
try load(options: startupOptions)
try loadVendorOptions(from: tunnelProviderProtocol)
+
+ if (try? tokenStore.fetchToken()) == nil {
+ throw TunnelError.startingTunnelWithoutAuthToken
+ }
} catch {
if startupOptions.startupMethod == .automaticOnDemand {
// If the VPN was started by on-demand without the basic prerequisites for
@@ -585,9 +603,10 @@ open class PacketTunnelProvider: NEPacketTunnelProvider {
// manual start attempt that preceded failed, or if the subscription has
// expired. In either case it should be enough to record the manual failures
// for these prerequisited to avoid flooding our metrics.
- try? await Task.sleep(interval: .seconds(15))
+ providerEvents.fire(.tunnelStartOnDemandWithoutAccessToken)
+ try await Task.sleep(interval: .seconds(15))
} else {
- // If the VPN was started manually without the basic prerequisited we always
+ // If the VPN was started manually without the basic prerequisites we always
// want to know as this should not be possible.
providerEvents.fire(.tunnelStartAttempt(.begin))
providerEvents.fire(.tunnelStartAttempt(.failure(error)))
@@ -613,19 +632,16 @@ open class PacketTunnelProvider: NEPacketTunnelProvider {
// We add a delay when the VPN is started by
// on-demand and there's an error, to avoid frenetic ON/OFF
// cycling.
- try? await Task.sleep(interval: .seconds(15))
+ try await Task.sleep(interval: .seconds(15))
}
let errorDescription = (error as? LocalizedError)?.localizedDescription ?? String(describing: error)
- os_log("Tunnel startup error: %{public}@", type: .error, errorDescription)
self.controllerErrorStore.lastErrorMessage = errorDescription
self.connectionStatus = .disconnected
self.knownFailureStore.lastKnownFailure = KnownFailure(error)
providerEvents.fire(.tunnelStartAttempt(.failure(error)))
-
- os_log("🔴 Stopping VPN due to error: %{public}s", log: .networkProtection, error.localizedDescription)
throw error
}
}
@@ -654,20 +670,19 @@ open class PacketTunnelProvider: NEPacketTunnelProvider {
private func startTunnel(onDemand: Bool) async throws {
do {
- os_log("🔵 Generating tunnel config", log: .networkProtection, type: .info)
- os_log("🔵 Excluded ranges are: %{public}@", log: .networkProtection, type: .info, String(describing: settings.excludedRanges))
- os_log("🔵 Server selection method: %{public}@", log: .networkProtection, type: .info, currentServerSelectionMethod.debugDescription)
+ os_log("Generating tunnel config", log: .networkProtection)
+ os_log("Excluded ranges are: %{public}@", log: .networkProtection, String(describing: settings.excludedRanges))
+ os_log("Server selection method: %{public}@", log: .networkProtection, currentServerSelectionMethod.debugDescription)
+ os_log("DNS server: %{public}@", log: .networkProtection, String(describing: settings.dnsSettings))
let tunnelConfiguration = try await generateTunnelConfiguration(serverSelectionMethod: currentServerSelectionMethod,
includedRoutes: includedRoutes ?? [],
excludedRoutes: settings.excludedRanges,
+ dnsSettings: settings.dnsSettings,
regenerateKey: true)
try await startTunnel(with: tunnelConfiguration, onDemand: onDemand)
- os_log("🔵 Done generating tunnel config", log: .networkProtection, type: .info)
+ os_log("Done generating tunnel config", log: .networkProtection)
} catch {
- os_log("🔵 Error starting tunnel: %{public}@", log: .networkProtection, type: .info, error.localizedDescription)
-
controllerErrorStore.lastErrorMessage = error.localizedDescription
-
throw error
}
}
@@ -677,7 +692,6 @@ open class PacketTunnelProvider: NEPacketTunnelProvider {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in
adapter.start(tunnelConfiguration: tunnelConfiguration) { [weak self] error in
if let error {
- os_log("🔵 Starting tunnel failed with %{public}@", log: .networkProtection, type: .error, error.localizedDescription)
self?.debugEvents?.fire(error.networkProtectionError)
continuation.resume(throwing: error)
return
@@ -699,7 +713,7 @@ open class PacketTunnelProvider: NEPacketTunnelProvider {
#if os(iOS)
if #available(iOS 17.0, *), startReason == .manual {
try? await updateConnectOnDemand(enabled: true)
- os_log("Enabled Connect on Demand due to user-initiated startup", log: .networkProtection, type: .info)
+ os_log("Enabled Connect on Demand due to user-initiated startup", log: .networkProtection)
}
#endif
} catch {
@@ -717,7 +731,7 @@ open class PacketTunnelProvider: NEPacketTunnelProvider {
open override func stopTunnel(with reason: NEProviderStopReason) async {
providerEvents.fire(.tunnelStopAttempt(.begin))
- os_log("Stopping tunnel with reason %{public}@", log: .networkProtection, type: .info, String(describing: reason))
+ os_log("Stopping tunnel with reason %{public}@", log: .networkProtection, String(describing: reason))
do {
try await stopTunnel()
@@ -726,7 +740,7 @@ open class PacketTunnelProvider: NEPacketTunnelProvider {
// Disable Connect on Demand when disabling the tunnel from iOS settings on iOS 17.0+.
if #available(iOS 17.0, *), case .userInitiated = reason {
try? await updateConnectOnDemand(enabled: false)
- os_log("Disabled Connect on Demand due to user-initiated shutdown", log: .networkProtection, type: .info)
+ os_log("Disabled Connect on Demand due to user-initiated shutdown", log: .networkProtection)
}
} catch {
providerEvents.fire(.tunnelStopAttempt(.failure(error)))
@@ -774,7 +788,6 @@ open class PacketTunnelProvider: NEPacketTunnelProvider {
}
if let error {
- os_log("🔵 Error while stopping adapter: %{public}@", log: .networkProtection, type: .error, error.localizedDescription)
self?.debugEvents?.fire(error.networkProtectionError)
continuation.resume(throwing: error)
@@ -804,19 +817,15 @@ open class PacketTunnelProvider: NEPacketTunnelProvider {
// MARK: - Tunnel Configuration
- @MainActor
- public func updateTunnelConfiguration(reassert: Bool, regenerateKey: Bool = false) async throws {
- try await updateTunnelConfiguration(
- serverSelectionMethod: currentServerSelectionMethod,
- reassert: reassert,
- regenerateKey: regenerateKey
- )
+ enum TunnelUpdateMethod {
+ case selectServer(_ method: NetworkProtectionServerSelectionMethod)
+ case useConfiguration(_ configuration: TunnelConfiguration)
}
@MainActor
- public func updateTunnelConfiguration(serverSelectionMethod: NetworkProtectionServerSelectionMethod,
- reassert: Bool,
- regenerateKey: Bool = false) async throws {
+ func updateTunnelConfiguration(updateMethod: TunnelUpdateMethod,
+ reassert: Bool,
+ regenerateKey: Bool = false) async throws {
providerEvents.fire(.tunnelUpdateAttempt(.begin))
@@ -824,54 +833,50 @@ open class PacketTunnelProvider: NEPacketTunnelProvider {
await stopMonitors()
}
- let tunnelConfiguration: TunnelConfiguration
do {
- tunnelConfiguration = try await generateTunnelConfiguration(serverSelectionMethod: serverSelectionMethod,
+ let tunnelConfiguration: TunnelConfiguration
+
+ switch updateMethod {
+ case .selectServer(let serverSelectionMethod):
+ tunnelConfiguration = try await generateTunnelConfiguration(serverSelectionMethod: serverSelectionMethod,
includedRoutes: includedRoutes ?? [],
excludedRoutes: settings.excludedRanges,
+ dnsSettings: settings.dnsSettings,
regenerateKey: regenerateKey)
+ case .useConfiguration(let newTunnelConfiguration):
+ tunnelConfiguration = newTunnelConfiguration
+ }
+
+ try await updateAdapterConfiguration(tunnelConfiguration: tunnelConfiguration, reassert: reassert)
+
+ if reassert {
+ try await handleAdapterStarted(startReason: .reconnected)
+ }
+
+ providerEvents.fire(.tunnelUpdateAttempt(.success))
} catch {
providerEvents.fire(.tunnelUpdateAttempt(.failure(error)))
throw error
}
- try await updateAdapterConfiguration(tunnelConfiguration: tunnelConfiguration, reassert: reassert)
}
@MainActor
private func updateAdapterConfiguration(tunnelConfiguration: TunnelConfiguration, reassert: Bool) async throws {
- do {
- try await withCheckedThrowingContinuation { [weak self] (continuation: CheckedContinuation) in
- guard let self = self else {
- continuation.resume()
+ try await withCheckedThrowingContinuation { [weak self] (continuation: CheckedContinuation) in
+ guard let self = self else {
+ continuation.resume()
+ return
+ }
+
+ self.adapter.update(tunnelConfiguration: tunnelConfiguration, reassert: reassert) { [weak self] error in
+ if let error = error {
+ self?.debugEvents?.fire(error.networkProtectionError)
+ continuation.resume(throwing: error)
return
}
- self.adapter.update(tunnelConfiguration: tunnelConfiguration, reassert: reassert) { [weak self] error in
- if let error = error {
- os_log("🔵 Failed to update the configuration: %{public}@", type: .error, error.localizedDescription)
- self?.debugEvents?.fire(error.networkProtectionError)
- continuation.resume(throwing: error)
- return
- }
-
- Task { [weak self] in
- if reassert {
- do {
- try await self?.handleAdapterStarted(startReason: .reconnected)
- } catch {
- continuation.resume(throwing: error)
- return
- }
- }
-
- continuation.resume()
- }
- }
+ continuation.resume()
}
- providerEvents.fire(.tunnelUpdateAttempt(.success))
- } catch {
- providerEvents.fire(.tunnelUpdateAttempt(.failure(error)))
- throw error
}
}
@@ -879,6 +884,7 @@ open class PacketTunnelProvider: NEPacketTunnelProvider {
private func generateTunnelConfiguration(serverSelectionMethod: NetworkProtectionServerSelectionMethod,
includedRoutes: [IPAddressRange],
excludedRoutes: [IPAddressRange],
+ dnsSettings: NetworkProtectionDNSSettings,
regenerateKey: Bool) async throws -> TunnelConfiguration {
let configurationResult: NetworkProtectionDeviceManager.GenerateTunnelConfigurationResult
@@ -888,6 +894,7 @@ open class PacketTunnelProvider: NEPacketTunnelProvider {
selectionMethod: serverSelectionMethod,
includedRoutes: includedRoutes,
excludedRoutes: excludedRoutes,
+ dnsSettings: dnsSettings,
isKillSwitchEnabled: isKillSwitchEnabled,
regenerateKey: regenerateKey
)
@@ -903,11 +910,11 @@ open class PacketTunnelProvider: NEPacketTunnelProvider {
let newSelectedServer = configurationResult.server
self.lastSelectedServer = newSelectedServer
- os_log("🔵 Generated tunnel configuration for server at location: %{public}s (preferred server is %{public}s)",
+ os_log("⚪️ Generated tunnel configuration for server at location: %{public}s (preferred server is %{public}s)",
log: .networkProtection,
newSelectedServer.serverInfo.serverLocation,
newSelectedServer.serverInfo.name)
- os_log("🔵 Excluded routes: %{public}@", log: .networkProtection, type: .info, String(describing: excludedRoutes))
+ os_log("Excluded routes: %{public}@", log: .networkProtection, String(describing: excludedRoutes))
return configurationResult.tunnelConfiguration
}
@@ -925,11 +932,19 @@ open class PacketTunnelProvider: NEPacketTunnelProvider {
// swiftlint:disable:next cyclomatic_complexity
@MainActor public override func handleAppMessage(_ messageData: Data, completionHandler: ((Data?) -> Void)? = nil) {
+
guard let message = ExtensionMessage(rawValue: messageData) else {
+ os_log("🔴 Received unknown app message", log: .networkProtectionIPCLog, type: .error)
completionHandler?(nil)
return
}
+ /// We're skipping messages that are very frequent and not likely to affect anything in terms of functionality.
+ /// We can opt to aggregate them somehow if we ever need them - for now I'm disabling.
+ if message != .getDataVolume {
+ os_log("⚪️ Received app message: %{public}@", log: .networkProtectionIPCLog, String(describing: message))
+ }
+
switch message {
case .request(let request):
handleRequest(request, completionHandler: completionHandler)
@@ -987,13 +1002,15 @@ open class PacketTunnelProvider: NEPacketTunnelProvider {
settings.apply(change: change)
}
- // swiftlint:disable:next cyclomatic_complexity
+ // swiftlint:disable:next cyclomatic_complexity function_body_length
private func handleSettingsChange(_ change: VPNSettings.Change, completionHandler: ((Data?) -> Void)? = nil) {
switch change {
case .setExcludeLocalNetworks:
Task { @MainActor in
if case .connected = connectionStatus {
- try? await updateTunnelConfiguration(reassert: false)
+ try? await updateTunnelConfiguration(
+ updateMethod: .selectServer(currentServerSelectionMethod),
+ reassert: false)
}
completionHandler?(nil)
}
@@ -1009,7 +1026,9 @@ open class PacketTunnelProvider: NEPacketTunnelProvider {
Task { @MainActor in
if case .connected = connectionStatus {
- try? await updateTunnelConfiguration(serverSelectionMethod: serverSelectionMethod, reassert: true)
+ try? await updateTunnelConfiguration(
+ updateMethod: .selectServer(serverSelectionMethod),
+ reassert: true)
}
completionHandler?(nil)
}
@@ -1025,7 +1044,18 @@ open class PacketTunnelProvider: NEPacketTunnelProvider {
Task { @MainActor in
if case .connected = connectionStatus {
- try? await updateTunnelConfiguration(serverSelectionMethod: serverSelectionMethod, reassert: true)
+ try? await updateTunnelConfiguration(
+ updateMethod: .selectServer(serverSelectionMethod),
+ reassert: true)
+ }
+ completionHandler?(nil)
+ }
+ case .setDNSSettings:
+ Task { @MainActor in
+ if case .connected = connectionStatus {
+ try? await updateTunnelConfiguration(
+ updateMethod: .selectServer(currentServerSelectionMethod),
+ reassert: true)
}
completionHandler?(nil)
}
@@ -1062,6 +1092,9 @@ open class PacketTunnelProvider: NEPacketTunnelProvider {
case .uninstallVPN:
// Since the VPN configuration is being removed we may as well reset all state
handleResetAllState(completionHandler: completionHandler)
+ case .quitAgent:
+ // No-op since this is intended for the agent app
+ break
}
}
@@ -1110,7 +1143,9 @@ open class PacketTunnelProvider: NEPacketTunnelProvider {
settings.selectedServer = .automatic
if case .connected = connectionStatus {
- try? await updateTunnelConfiguration(reassert: true)
+ try? await updateTunnelConfiguration(
+ updateMethod: .selectServer(currentServerSelectionMethod),
+ reassert: true)
}
}
completionHandler?(nil)
@@ -1124,7 +1159,9 @@ open class PacketTunnelProvider: NEPacketTunnelProvider {
settings.selectedServer = .endpoint(serverName)
if case .connected = connectionStatus {
- try? await updateTunnelConfiguration(serverSelectionMethod: .preferredServer(serverName: serverName), reassert: true)
+ try? await updateTunnelConfiguration(
+ updateMethod: .selectServer(.preferredServer(serverName: serverName)),
+ reassert: true)
}
completionHandler?(nil)
}
@@ -1175,7 +1212,7 @@ open class PacketTunnelProvider: NEPacketTunnelProvider {
return
}
- os_log("🔵 Disabling Connect On Demand and shutting down the tunnel", log: .networkProtection, type: .info)
+ os_log("⚪️ Disabling Connect On Demand and shutting down the tunnel", log: .networkProtection)
manager.isOnDemandEnabled = false
try await manager.saveToPreferences()
@@ -1193,7 +1230,9 @@ open class PacketTunnelProvider: NEPacketTunnelProvider {
self.includedRoutes = includedRoutes
if case .connected = connectionStatus {
- try? await updateTunnelConfiguration(reassert: false)
+ try? await updateTunnelConfiguration(
+ updateMethod: .selectServer(currentServerSelectionMethod),
+ reassert: false)
}
completionHandler?(nil)
}
@@ -1201,12 +1240,12 @@ open class PacketTunnelProvider: NEPacketTunnelProvider {
private func simulateTunnelFailure(completionHandler: ((Data?) -> Void)? = nil) {
Task {
- os_log("Simulating tunnel failure", log: .networkProtection, type: .info)
+ os_log("Simulating tunnel failure", log: .networkProtection)
adapter.stop { [weak self] error in
if let error {
self?.debugEvents?.fire(error.networkProtectionError)
- os_log("🔵 Failed to stop WireGuard adapter: %{public}@", log: .networkProtection, type: .info, error.localizedDescription)
+ os_log("🔴 Failed to stop WireGuard adapter: %{public}@", log: .networkProtection, error.localizedDescription)
}
completionHandler?(error.map { ExtensionMessageString($0.localizedDescription).rawValue })
@@ -1263,14 +1302,7 @@ open class PacketTunnelProvider: NEPacketTunnelProvider {
connectionStatus = .connected(connectedDate: Date())
}
- if !settings.disableRekeying {
- guard !isKeyExpired else {
- try await rekey()
- return
- }
- }
-
- os_log("🔵 Tunnel interface is %{public}@", log: .networkProtection, type: .info, adapter.interfaceName ?? "unknown")
+ os_log("⚪️ Tunnel interface is %{public}@", log: .networkProtection, adapter.interfaceName ?? "unknown")
// These cases only make sense in the context of a connection that had trouble
// and is being fixed, so we want to test the connection immediately.
@@ -1326,6 +1358,7 @@ open class PacketTunnelProvider: NEPacketTunnelProvider {
to: server,
includedRoutes: self.includedRoutes ?? [],
excludedRoutes: self.settings.excludedRanges,
+ dnsSettings: self.settings.dnsSettings,
isKillSwitchEnabled: self.isKillSwitchEnabled
) { [weak self] generateConfigResult in
try await self?.handleFailureRecoveryConfigUpdate(result: generateConfigResult)
@@ -1337,7 +1370,7 @@ open class PacketTunnelProvider: NEPacketTunnelProvider {
@MainActor
private func handleFailureRecoveryConfigUpdate(result: NetworkProtectionDeviceManagement.GenerateTunnelConfigurationResult) async throws {
self.lastSelectedServer = result.server
- try await self.updateAdapterConfiguration(tunnelConfiguration: result.tunnelConfiguration, reassert: true)
+ try await updateTunnelConfiguration(updateMethod: .useConfiguration(result.tunnelConfiguration), reassert: true)
}
@MainActor
@@ -1397,13 +1430,19 @@ open class PacketTunnelProvider: NEPacketTunnelProvider {
await serverStatusMonitor.start(serverName: serverName) { status in
if status.shouldMigrate {
- Task {
- self.providerEvents.fire(.serverMigrationAttempt(.begin))
+ Task { [ weak self] in
+ guard let self else { return }
+
+ providerEvents.fire(.serverMigrationAttempt(.begin))
+
do {
- try await self.updateTunnelConfiguration(reassert: true, regenerateKey: true)
- self.providerEvents.fire(.serverMigrationAttempt(.success))
+ try await self.updateTunnelConfiguration(
+ updateMethod: .selectServer(currentServerSelectionMethod),
+ reassert: true,
+ regenerateKey: true)
+ providerEvents.fire(.serverMigrationAttempt(.success))
} catch {
- self.providerEvents.fire(.serverMigrationAttempt(.failure(error)))
+ providerEvents.fire(.serverMigrationAttempt(.failure(error)))
}
}
}
@@ -1446,7 +1485,7 @@ open class PacketTunnelProvider: NEPacketTunnelProvider {
do {
try await startConnectionTester(testImmediately: testImmediately)
} catch {
- os_log("🔵 Connection Tester error: %{public}@", log: .networkProtectionConnectionTesterLog, type: .error, String(reflecting: error))
+ os_log("🔴 Connection Tester error: %{public}@", log: .networkProtectionConnectionTesterLog, type: .error, String(reflecting: error))
throw error
}
}
@@ -1516,26 +1555,34 @@ open class PacketTunnelProvider: NEPacketTunnelProvider {
// MARK: - Computer sleeping
+ @MainActor
public override func sleep() async {
- os_log("Sleep", log: .networkProtectionSleepLog, type: .info)
+ os_log("Sleep", log: .networkProtectionSleepLog)
- await connectionTester.stop()
- await tunnelFailureMonitor.stop()
- await latencyMonitor.stop()
- await entitlementMonitor.stop()
- await serverStatusMonitor.stop()
+ await stopMonitors()
}
+ @MainActor
public override func wake() {
- os_log("Wake up", log: .networkProtectionSleepLog, type: .info)
+ os_log("Wake up", log: .networkProtectionSleepLog)
+
+ // macOS can launch the extension due to calls to `sendProviderMessage`, so there's
+ // a chance this is being called when the VPN isn't really meant to be connected or
+ // running. We want to avoid firing pixels or handling adapter changes when this is
+ // the case.
+ guard connectionStatus != .disconnected else {
+ return
+ }
Task {
providerEvents.fire(.tunnelWakeAttempt(.begin))
do {
try await handleAdapterStarted(startReason: .wake)
+ os_log("🟢 Wake success", log: .networkProtectionConnectionTesterLog)
providerEvents.fire(.tunnelWakeAttempt(.success))
} catch {
+ os_log("🔴 Wake error: ${public}@", log: .networkProtectionConnectionTesterLog, type: .error, error.localizedDescription)
providerEvents.fire(.tunnelWakeAttempt(.failure(error)))
}
}
diff --git a/Sources/NetworkProtection/Recovery/FailureRecoveryHandler.swift b/Sources/NetworkProtection/Recovery/FailureRecoveryHandler.swift
index 7fd22f042..9d0ad2210 100644
--- a/Sources/NetworkProtection/Recovery/FailureRecoveryHandler.swift
+++ b/Sources/NetworkProtection/Recovery/FailureRecoveryHandler.swift
@@ -35,6 +35,7 @@ protocol FailureRecoveryHandling {
to lastConnectedServer: NetworkProtectionServer,
includedRoutes: [IPAddressRange],
excludedRoutes: [IPAddressRange],
+ dnsSettings: NetworkProtectionDNSSettings,
isKillSwitchEnabled: Bool,
updateConfig: @escaping (NetworkProtectionDeviceManagement.GenerateTunnelConfigurationResult) async throws -> Void
) async
@@ -85,6 +86,7 @@ actor FailureRecoveryHandler: FailureRecoveryHandling {
to lastConnectedServer: NetworkProtectionServer,
includedRoutes: [IPAddressRange],
excludedRoutes: [IPAddressRange],
+ dnsSettings: NetworkProtectionDNSSettings,
isKillSwitchEnabled: Bool,
updateConfig: @escaping (NetworkProtectionDeviceManagement.GenerateTunnelConfigurationResult) async throws -> Void
) async {
@@ -92,6 +94,7 @@ actor FailureRecoveryHandler: FailureRecoveryHandling {
defer {
reassertingControl?.stopReasserting()
}
+ let eventHandler = eventHandler
await incrementalPeriodicChecks(retryConfig) { [weak self] in
guard let self else { return }
eventHandler(.started)
@@ -100,6 +103,7 @@ actor FailureRecoveryHandler: FailureRecoveryHandling {
to: lastConnectedServer,
includedRoutes: includedRoutes,
excludedRoutes: excludedRoutes,
+ dnsSettings: dnsSettings,
isKillSwitchEnabled: isKillSwitchEnabled
)
switch result {
@@ -127,6 +131,7 @@ actor FailureRecoveryHandler: FailureRecoveryHandling {
to lastConnectedServer: NetworkProtectionServer,
includedRoutes: [IPAddressRange],
excludedRoutes: [IPAddressRange],
+ dnsSettings: NetworkProtectionDNSSettings,
isKillSwitchEnabled: Bool
) async throws -> FailureRecoveryResult {
let serverSelectionMethod: NetworkProtectionServerSelectionMethod = .failureRecovery(serverName: lastConnectedServer.serverName)
@@ -136,17 +141,17 @@ actor FailureRecoveryHandler: FailureRecoveryHandling {
selectionMethod: serverSelectionMethod,
includedRoutes: includedRoutes,
excludedRoutes: excludedRoutes,
+ dnsSettings: dnsSettings,
isKillSwitchEnabled: isKillSwitchEnabled,
regenerateKey: false
)
- os_log("🟢 Failure recovery fetched new config.", log: .networkProtectionServerFailureRecoveryLog, type: .info)
+ os_log("🟢 Failure recovery fetched new config.", log: .networkProtectionTunnelFailureMonitorLog)
let newServer = configurationResult.server
os_log(
"🟢 Failure recovery - originalServerName: %{public}s, newServerName: %{public}s, originalAllowedIPs: %{public}s, newAllowedIPs: %{public}s",
log: .networkProtection,
- type: .info,
lastConnectedServer.serverName,
newServer.serverName,
String(describing: lastConnectedServer.allowedIPs),
@@ -154,7 +159,7 @@ actor FailureRecoveryHandler: FailureRecoveryHandling {
)
guard lastConnectedServer.shouldReplace(with: newServer) else {
- os_log("🟢 Server failure recovery not necessary.", log: .networkProtectionServerFailureRecoveryLog, type: .info)
+ os_log("🟢 Server failure recovery not necessary.", log: .networkProtectionTunnelFailureMonitorLog)
return .noRecoveryNecessary
}
@@ -177,10 +182,10 @@ actor FailureRecoveryHandler: FailureRecoveryHandling {
}
do {
try await action()
- os_log("🟢 Failure recovery success!", log: .networkProtectionServerFailureRecoveryLog, type: .info)
+ os_log("🟢 Failure recovery success!", log: .networkProtectionTunnelFailureMonitorLog)
return
} catch {
- os_log("🟢 Failure recovery failed. Retrying...", log: .networkProtectionServerFailureRecoveryLog, type: .info)
+ os_log("🟢 Failure recovery failed. Retrying...", log: .networkProtectionTunnelFailureMonitorLog)
}
do {
try await Task.sleep(interval: currentDelay)
diff --git a/Sources/NetworkProtection/Recovery/Reasserting.swift b/Sources/NetworkProtection/Recovery/Reasserting.swift
index 13848f3d9..6f266a2fb 100644
--- a/Sources/NetworkProtection/Recovery/Reasserting.swift
+++ b/Sources/NetworkProtection/Recovery/Reasserting.swift
@@ -26,12 +26,10 @@ protocol Reasserting: AnyObject {
extension NEPacketTunnelProvider: Reasserting {
- @MainActor
func startReasserting() {
reasserting = true
}
- @MainActor
func stopReasserting() {
reasserting = false
}
diff --git a/Sources/NetworkProtection/Settings/Extensions/UserDefaults+dnsSettings.swift b/Sources/NetworkProtection/Settings/Extensions/UserDefaults+dnsSettings.swift
new file mode 100644
index 000000000..7c1432ac2
--- /dev/null
+++ b/Sources/NetworkProtection/Settings/Extensions/UserDefaults+dnsSettings.swift
@@ -0,0 +1,85 @@
+//
+// UserDefaults+dnsSettings.swift
+//
+// Copyright © 2024 DuckDuckGo. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Combine
+import Foundation
+
+extension UserDefaults {
+ final class StorableDNSSettings: NSObject, Codable {
+ let usesCustomDNS: Bool
+ let dnsServers: [String]
+
+ init(usesCustomDNS: Bool = false, dnsServers: [String] = []) {
+ self.usesCustomDNS = usesCustomDNS
+ self.dnsServers = dnsServers
+ }
+ }
+
+ private var dnsSettingKey: String {
+ "dnsSettingStorageValue"
+ }
+
+ private static func dnsSettingsFromStorageValue(_ value: StorableDNSSettings) -> NetworkProtectionDNSSettings {
+ guard value.usesCustomDNS, !value.dnsServers.isEmpty else { return .default }
+ return .custom(value.dnsServers)
+ }
+
+ @objc
+ dynamic var dnsSettingStorageValue: StorableDNSSettings {
+ get {
+ guard let data = data(forKey: dnsSettingKey) else { return StorableDNSSettings() }
+ return (try? JSONDecoder().decode(StorableDNSSettings.self, from: data)) ?? StorableDNSSettings()
+ }
+
+ set {
+ if let data = try? JSONEncoder().encode(newValue) {
+ set(data, forKey: dnsSettingKey)
+ }
+ }
+ }
+
+ var dnsSettings: NetworkProtectionDNSSettings {
+ get {
+ Self.dnsSettingsFromStorageValue(dnsSettingStorageValue)
+ }
+
+ set {
+ switch newValue {
+ case .default:
+ dnsSettingStorageValue = StorableDNSSettings()
+ case .custom(let dnsServers):
+ let hosts = dnsServers.compactMap(\.toIPv4Host)
+ if hosts.isEmpty {
+ dnsSettingStorageValue = StorableDNSSettings()
+ } else {
+ dnsSettingStorageValue = StorableDNSSettings(usesCustomDNS: true, dnsServers: hosts)
+ }
+ }
+ }
+ }
+
+ var dnsSettingsPublisher: AnyPublisher {
+ publisher(for: \.dnsSettingStorageValue)
+ .map(Self.dnsSettingsFromStorageValue(_:))
+ .eraseToAnyPublisher()
+ }
+
+ func resetDNSSettings() {
+ dnsSettings = .default
+ }
+}
diff --git a/Sources/NetworkProtection/Settings/VPNSettings.swift b/Sources/NetworkProtection/Settings/VPNSettings.swift
index 5c57c43e6..d568b8acc 100644
--- a/Sources/NetworkProtection/Settings/VPNSettings.swift
+++ b/Sources/NetworkProtection/Settings/VPNSettings.swift
@@ -37,6 +37,7 @@ public final class VPNSettings {
case setSelectedServer(_ selectedServer: SelectedServer)
case setSelectedLocation(_ selectedLocation: SelectedLocation)
case setSelectedEnvironment(_ selectedEnvironment: SelectedEnvironment)
+ case setDNSSettings(_ dnsSettings: NetworkProtectionDNSSettings)
case setShowInMenuBar(_ showInMenuBar: Bool)
case setDisableRekeying(_ disableRekeying: Bool)
}
@@ -153,6 +154,13 @@ public final class VPNSettings {
Change.setSelectedEnvironment(environment)
}.eraseToAnyPublisher()
+ let dnsSettingsChangePublisher = dnsSettingsPublisher
+ .dropFirst()
+ .removeDuplicates()
+ .map { settings in
+ Change.setDNSSettings(settings)
+ }.eraseToAnyPublisher()
+
let showInMenuBarPublisher = showInMenuBarPublisher
.dropFirst()
.removeDuplicates()
@@ -176,6 +184,7 @@ public final class VPNSettings {
serverChangePublisher,
locationChangePublisher,
environmentChangePublisher,
+ dnsSettingsChangePublisher,
showInMenuBarPublisher,
disableRekeyingPublisher).eraseToAnyPublisher()
}()
@@ -194,6 +203,7 @@ public final class VPNSettings {
defaults.resetNetworkProtectionSettingNotifyStatusChanges()
defaults.resetNetworkProtectionSettingRegistrationKeyValidity()
defaults.resetNetworkProtectionSettingSelectedServer()
+ defaults.resetDNSSettings()
defaults.resetNetworkProtectionSettingShowInMenuBar()
}
@@ -220,6 +230,8 @@ public final class VPNSettings {
self.selectedLocation = selectedLocation
case .setSelectedEnvironment(let selectedEnvironment):
self.selectedEnvironment = selectedEnvironment
+ case .setDNSSettings(let dnsSettings):
+ self.dnsSettings = dnsSettings
case .setShowInMenuBar(let showInMenuBar):
self.showInMenuBar = showInMenuBar
case .setDisableRekeying(let disableRekeying):
@@ -360,6 +372,22 @@ public final class VPNSettings {
}
}
+ // MARK: - DNS Settings
+
+ public var dnsSettingsPublisher: AnyPublisher {
+ defaults.dnsSettingsPublisher
+ }
+
+ public var dnsSettings: NetworkProtectionDNSSettings {
+ get {
+ defaults.dnsSettings
+ }
+
+ set {
+ defaults.dnsSettings = newValue
+ }
+ }
+
// MARK: - Show in Menu Bar
public var showInMenuBarPublisher: AnyPublisher {
diff --git a/Sources/NetworkProtection/StartupOptions.swift b/Sources/NetworkProtection/StartupOptions.swift
index f895c8fc8..201cf2f81 100644
--- a/Sources/NetworkProtection/StartupOptions.swift
+++ b/Sources/NetworkProtection/StartupOptions.swift
@@ -107,6 +107,7 @@ struct StartupOptions {
let selectedEnvironment: StoredOption
let selectedServer: StoredOption
let selectedLocation: StoredOption
+ let dnsSettings: StoredOption
#if os(macOS)
let authToken: StoredOption
#endif
@@ -138,6 +139,7 @@ struct StartupOptions {
selectedEnvironment = Self.readSelectedEnvironment(from: options, resetIfNil: resetStoredOptionsIfNil)
selectedServer = Self.readSelectedServer(from: options, resetIfNil: resetStoredOptionsIfNil)
selectedLocation = Self.readSelectedLocation(from: options, resetIfNil: resetStoredOptionsIfNil)
+ dnsSettings = Self.readDNSSettings(from: options, resetIfNil: resetStoredOptionsIfNil)
}
var description: String {
@@ -151,6 +153,7 @@ struct StartupOptions {
selectedEnvironment: \(self.selectedEnvironment.description),
selectedServer: \(self.selectedServer.description),
selectedLocation: \(self.selectedLocation.description),
+ dnsSettings: \(self.dnsSettings.description),
enableTester: \(self.enableTester)
)
"""
@@ -216,6 +219,17 @@ struct StartupOptions {
}
}
+ private static func readDNSSettings(from options: [String: Any], resetIfNil: Bool) -> StoredOption {
+ StoredOption(resetIfNil: resetIfNil) {
+ guard let data = options[NetworkProtectionOptionKey.dnsSettings] as? Data,
+ let dnsSettings = try? JSONDecoder().decode(NetworkProtectionDNSSettings.self, from: data) else {
+ return nil
+ }
+
+ return dnsSettings
+ }
+ }
+
private static func readEnableTester(from options: [String: Any], resetIfNil: Bool) -> StoredOption {
StoredOption(resetIfNil: resetIfNil) {
guard let value = options[NetworkProtectionOptionKey.connectionTesterEnabled] as? Bool else {
diff --git a/Sources/NetworkProtectionTestUtils/MockNetworkProtectionDeviceManagement.swift b/Sources/NetworkProtectionTestUtils/MockNetworkProtectionDeviceManagement.swift
index 8c9e30e17..fad0a52e6 100644
--- a/Sources/NetworkProtectionTestUtils/MockNetworkProtectionDeviceManagement.swift
+++ b/Sources/NetworkProtectionTestUtils/MockNetworkProtectionDeviceManagement.swift
@@ -46,6 +46,7 @@ public final class MockNetworkProtectionDeviceManagement: NetworkProtectionDevic
selectionMethod: NetworkProtection.NetworkProtectionServerSelectionMethod,
includedRoutes: [NetworkProtection.IPAddressRange],
excludedRoutes: [NetworkProtection.IPAddressRange],
+ dnsSettings: NetworkProtectionDNSSettings,
isKillSwitchEnabled: Bool,
regenerateKey: Bool) async throws -> (tunnelConfiguration: NetworkProtection.TunnelConfiguration, server: NetworkProtection.NetworkProtectionServer) {
spyGenerateTunnelConfiguration = (
diff --git a/Sources/PrivacyDashboard/BrokenSiteReporting/ReferrerInfo.swift b/Sources/PrivacyDashboard/BrokenSiteReporting/ReferrerInfo.swift
index b62280115..9a78ca35d 100644
--- a/Sources/PrivacyDashboard/BrokenSiteReporting/ReferrerInfo.swift
+++ b/Sources/PrivacyDashboard/BrokenSiteReporting/ReferrerInfo.swift
@@ -16,22 +16,24 @@
// limitations under the License.
//
+import Common
import Foundation
import WebKit
extension WKWebView {
+
@MainActor
- public var isCurrentSiteReferredFromDuckDuckGo: Bool {
+ var referrer: String? {
get async {
- do {
- if let result = try await self.evaluateJavaScript("document.referrer") as? String {
- return result.contains("duckduckgo.com")
- }
- } catch {
- return false
- }
+ try? await self.evaluateJavaScript("document.referrer")
+ }
+ }
- return false
+ @MainActor
+ public var isCurrentSiteReferredFromDuckDuckGo: Bool {
+ get async {
+ await referrer?.contains("duckduckgo.com") ?? false
}
}
+
}
diff --git a/Sources/PrivacyDashboard/Model/TrackerInfo.swift b/Sources/PrivacyDashboard/Model/TrackerInfo.swift
index 5494c40da..d15d15dda 100644
--- a/Sources/PrivacyDashboard/Model/TrackerInfo.swift
+++ b/Sources/PrivacyDashboard/Model/TrackerInfo.swift
@@ -27,7 +27,7 @@ public struct TrackerInfo: Encodable {
case installedSurrogates
}
- public private (set) var trackers = Set()
+ public private(set) var trackers = Set()
private(set) var thirdPartyRequests = Set()
public private(set) var installedSurrogates = Set()
diff --git a/Sources/PrivacyDashboard/PrivacyDashboardController.swift b/Sources/PrivacyDashboard/PrivacyDashboardController.swift
index 07ed0ea45..9217de25c 100644
--- a/Sources/PrivacyDashboard/PrivacyDashboardController.swift
+++ b/Sources/PrivacyDashboard/PrivacyDashboardController.swift
@@ -146,13 +146,17 @@ public protocol PrivacyDashboardControllerDelegate: AnyObject {
private weak var webView: WKWebView?
private let privacyDashboardScript: PrivacyDashboardUserScript
private var cancellables = Set()
+
private var protectionStateToSubmitOnToggleReportDismiss: ProtectionState?
+ private var didSendToggleReport: Bool = false
+
private let privacyConfigurationManager: PrivacyConfigurationManaging
private let eventMapping: EventMapping
private let variant: PrivacyDashboardVariant
private var toggleReportCounter: Int? { userDefaults.toggleReportCounter > 20 ? nil : userDefaults.toggleReportCounter }
+ private var toggleReportsManager: ToggleReportsManager
private let userDefaults: UserDefaults
private var didOpenReportInfo: Bool = false
@@ -170,6 +174,7 @@ public protocol PrivacyDashboardControllerDelegate: AnyObject {
privacyDashboardScript = PrivacyDashboardUserScript(privacyConfigurationManager: privacyConfigurationManager)
self.eventMapping = eventMapping
self.userDefaults = userDefaults
+ self.toggleReportsManager = ToggleReportsManager(feature: ToggleReportsFeature(manager: privacyConfigurationManager))
}
public func setup(for webView: WKWebView) {
@@ -377,11 +382,7 @@ extension PrivacyDashboardController: PrivacyDashboardUserScriptDelegate {
}
private func shouldSegueToToggleReportScreen(with protectionState: ProtectionState) -> Bool {
- !protectionState.isProtected && protectionState.eventOrigin.screen == .primaryScreen && isToggleReportsFeatureEnabled
- }
-
- private var isToggleReportsFeatureEnabled: Bool {
- return ToggleReportsFeature(privacyConfiguration: privacyConfigurationManager.privacyConfig).isEnabled
+ !protectionState.isProtected && protectionState.eventOrigin.screen == .primaryScreen && toggleReportsManager.shouldShowToggleReport
}
private func didChangeProtectionState(_ protectionState: ProtectionState, didSendReport: Bool = false) {
@@ -421,12 +422,14 @@ extension PrivacyDashboardController: PrivacyDashboardUserScriptDelegate {
private func handleDismiss(with type: ToggleReportDismissType, source: ToggleReportDismissSource) {
#if os(iOS)
+ // called when protection is toggled off from app menu
if case .toggleReport(completionHandler: let completionHandler) = initDashboardMode {
completionHandler(type == .send)
- fireToggleReportEventIfNeeded(for: type)
+ processToggleReport(for: type)
+ // called when protection is toggled off from privacy dashboard
} else if let protectionStateToSubmitOnToggleReportDismiss {
didChangeProtectionState(protectionStateToSubmitOnToggleReportDismiss, didSendReport: type == .send)
- fireToggleReportEventIfNeeded(for: type)
+ processToggleReport(for: type)
}
if source == .userScript {
closeDashboard()
@@ -437,13 +440,23 @@ extension PrivacyDashboardController: PrivacyDashboardUserScriptDelegate {
if type != .send {
if let protectionStateToSubmitOnToggleReportDismiss {
didChangeProtectionState(protectionStateToSubmitOnToggleReportDismiss)
- fireToggleReportEventIfNeeded(for: type)
+ if !didSendToggleReport {
+ fireToggleReportEventIfNeeded(for: type)
+ toggleReportsManager.recordDismissal()
+ }
}
closeDashboard()
}
#endif
}
+ private func processToggleReport(for type: ToggleReportDismissType) {
+ fireToggleReportEventIfNeeded(for: type)
+ if type != .send {
+ toggleReportsManager.recordDismissal()
+ }
+ }
+
public func handleViewWillDisappear() {
handleDismiss(with: .dismiss, source: .viewWillDisappear)
}
@@ -508,10 +521,12 @@ extension PrivacyDashboardController: PrivacyDashboardUserScriptDelegate {
func userScript(_ userScript: PrivacyDashboardUserScript, didSelectReportAction shouldSendReport: Bool) {
if shouldSendReport {
+ toggleReportsManager.recordPrompt()
privacyDashboardToggleReportDelegate?.privacyDashboardController(self,
didRequestSubmitToggleReportWithSource: source,
didOpenReportInfo: didOpenReportInfo,
toggleReportCounter: toggleReportCounter)
+ didSendToggleReport = true
}
let toggleReportDismissType: ToggleReportDismissType = shouldSendReport ? .send : .doNotSend
handleUserScriptClosing(toggleReportDismissType: toggleReportDismissType)
diff --git a/Sources/PrivacyDashboard/PrivacyDashboardUserScript.swift b/Sources/PrivacyDashboard/PrivacyDashboardUserScript.swift
index ecc2e4208..bdde66f0e 100644
--- a/Sources/PrivacyDashboard/PrivacyDashboardUserScript.swift
+++ b/Sources/PrivacyDashboard/PrivacyDashboardUserScript.swift
@@ -23,6 +23,7 @@ import UserScript
import Common
import BrowserServicesKit
+@MainActor
protocol PrivacyDashboardUserScriptDelegate: AnyObject {
func userScript(_ userScript: PrivacyDashboardUserScript, didChangeProtectionState protectionState: ProtectionState)
diff --git a/Sources/PrivacyDashboard/ToggleReportsFeature.swift b/Sources/PrivacyDashboard/ToggleReportsFeature.swift
index c78b99fb3..9e327f82d 100644
--- a/Sources/PrivacyDashboard/ToggleReportsFeature.swift
+++ b/Sources/PrivacyDashboard/ToggleReportsFeature.swift
@@ -17,19 +17,57 @@
//
import Foundation
+import Combine
import BrowserServicesKit
-public struct ToggleReportsFeature {
+public protocol ToggleReporting {
+
+ var isEnabled: Bool { get }
+
+ var isDismissLogicEnabled: Bool { get }
+ var dismissInterval: TimeInterval { get }
+
+ var isPromptLimitLogicEnabled: Bool { get }
+ var promptInterval: TimeInterval { get }
+ var maxPromptCount: Int { get }
+
+}
+
+public final class ToggleReportsFeature: ToggleReporting {
+
+ enum Constants {
+
+ static let dismissLogicEnabledKey = "dismissLogicEnabled"
+ static let dismissIntervalKey = "dismissInterval"
+
+ static let promptLimitLogicEnabledKey = "promptLimitLogicEnabled"
+ static let promptIntervalKey = "promptInterval"
+ static let maxPromptCountKey = "maxPromptCount"
+
+ static let defaultTimeInterval: TimeInterval = 48 * 60 * 60 // Two days
+ static let defaultPromptCount = 3
- public var privacyConfiguration: PrivacyConfiguration
- public init(privacyConfiguration: PrivacyConfiguration) {
- self.privacyConfiguration = privacyConfiguration
}
- public var isEnabled: Bool {
- let isFeatureEnabledInConfig = privacyConfiguration.isSubfeatureEnabled(PrivacyDashboardSubfeature.toggleReports)
+ public private(set) var isEnabled: Bool = false
+
+ public private(set) var isDismissLogicEnabled: Bool = true
+ public private(set) var dismissInterval: TimeInterval = 0
+
+ public private(set) var isPromptLimitLogicEnabled: Bool = true
+ public private(set) var promptInterval: TimeInterval = 0
+ public private(set) var maxPromptCount: Int = 0
+
+ public init(manager: PrivacyConfigurationManaging) {
let isCurrentLanguageEnglish = Locale.current.languageCode == "en"
- return isFeatureEnabledInConfig && isCurrentLanguageEnglish
+ isEnabled = manager.privacyConfig.isEnabled(featureKey: .toggleReports) && isCurrentLanguageEnglish
+ guard isEnabled else { return }
+ let settings = manager.privacyConfig.settings(for: .toggleReports)
+ isDismissLogicEnabled = settings[Constants.dismissLogicEnabledKey] as? Bool ?? false
+ dismissInterval = settings[Constants.dismissIntervalKey] as? TimeInterval ?? Constants.defaultTimeInterval
+ isPromptLimitLogicEnabled = settings[Constants.promptLimitLogicEnabledKey] as? Bool ?? false
+ promptInterval = settings[Constants.promptIntervalKey] as? TimeInterval ?? Constants.defaultTimeInterval
+ maxPromptCount = settings[Constants.maxPromptCountKey] as? Int ?? Constants.defaultPromptCount
}
}
diff --git a/Sources/PrivacyDashboard/ToggleReportsManager.swift b/Sources/PrivacyDashboard/ToggleReportsManager.swift
new file mode 100644
index 000000000..b481a0f5e
--- /dev/null
+++ b/Sources/PrivacyDashboard/ToggleReportsManager.swift
@@ -0,0 +1,118 @@
+//
+// ToggleReportsManager.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 BrowserServicesKit
+import Persistence
+
+public protocol ToggleReportsStoring {
+
+ var dismissedAt: Date? { get set }
+ var promptWindowStart: Date? { get set }
+ var promptCount: Int { get set }
+
+}
+
+public struct ToggleReportsStore: ToggleReportsStoring {
+
+ private enum Key {
+
+ static let dismissedAt = "com.duckduckgo.app.toggleReports.dismissedAt"
+ static let promptWindowStart = "com.duckduckgo.app.toggleReports.promptWindowStart"
+ static let promptCount = "com.duckduckgo.app.toggleReports.promptCount"
+
+ }
+
+ private let userDefaults: KeyValueStoring
+ public init(userDefaults: KeyValueStoring = UserDefaults()) {
+ self.userDefaults = userDefaults
+ }
+
+ public var dismissedAt: Date? {
+ get { userDefaults.object(forKey: Key.dismissedAt) as? Date }
+ set { userDefaults.set(newValue, forKey: Key.dismissedAt) }
+ }
+
+ public var promptWindowStart: Date? {
+ get { userDefaults.object(forKey: Key.promptWindowStart) as? Date }
+ set { userDefaults.set(newValue, forKey: Key.promptWindowStart) }
+ }
+
+ public var promptCount: Int {
+ get { userDefaults.object(forKey: Key.promptCount) as? Int ?? 0 }
+ set { userDefaults.set(newValue, forKey: Key.promptCount) }
+ }
+
+}
+
+public struct ToggleReportsManager {
+
+ private let feature: ToggleReporting
+ private var store: ToggleReportsStoring
+
+ public init(feature: ToggleReporting, store: ToggleReportsStoring = ToggleReportsStore()) {
+ self.store = store
+ self.feature = feature
+ }
+
+ public mutating func recordPrompt(date: Date = Date()) {
+ if let windowStart = store.promptWindowStart, date.timeIntervalSince(windowStart) > feature.promptInterval {
+ resetPromptWindow()
+ } else if store.promptWindowStart == nil {
+ startPromptWindow()
+ }
+ store.promptCount += 1
+
+ func resetPromptWindow() {
+ store.promptWindowStart = date
+ store.promptCount = 0
+ }
+
+ func startPromptWindow() {
+ store.promptWindowStart = date
+ }
+ }
+
+ public mutating func recordDismissal(date: Date = Date()) {
+ store.dismissedAt = date
+ }
+
+ public var shouldShowToggleReport: Bool { shouldShowToggleReport(date: Date()) }
+ public func shouldShowToggleReport(date: Date = Date()) -> Bool {
+ var didDismissalIntervalPass: Bool {
+ guard feature.isDismissLogicEnabled, let dismissedAt = store.dismissedAt else { return true }
+ let timeIntervalSinceLastDismiss = date.timeIntervalSince(dismissedAt)
+ return timeIntervalSinceLastDismiss > feature.dismissInterval
+ }
+
+ var isWithinPromptLimit: Bool {
+ guard feature.isPromptLimitLogicEnabled else { return true }
+ return store.promptCount < feature.maxPromptCount
+ }
+
+ var didPromptIntervalPass: Bool {
+ guard feature.isPromptLimitLogicEnabled, let windowStart = store.promptWindowStart else { return true }
+ let timeIntervalSincePromptWindowStart = date.timeIntervalSince(windowStart)
+ return timeIntervalSincePromptWindowStart > feature.promptInterval
+ }
+
+ guard feature.isEnabled else { return false }
+ return didDismissalIntervalPass && (isWithinPromptLimit || didPromptIntervalPass)
+ }
+
+}
diff --git a/Sources/RemoteMessaging/Matchers/UserAttributeMatcher.swift b/Sources/RemoteMessaging/Matchers/UserAttributeMatcher.swift
index 582665f4b..97e669c89 100644
--- a/Sources/RemoteMessaging/Matchers/UserAttributeMatcher.swift
+++ b/Sources/RemoteMessaging/Matchers/UserAttributeMatcher.swift
@@ -160,19 +160,19 @@ public struct UserAttributeMatcher: AttributeMatcher {
case let matchingAttribute as PrivacyProPurchasePlatformMatchingAttribute:
return StringArrayMatchingAttribute(matchingAttribute.value).matches(value: privacyProPurchasePlatform ?? "")
case let matchingAttribute as PrivacyProSubscriptionStatusMatchingAttribute:
- guard let value = matchingAttribute.value else {
- return .fail
+ let mappedStatuses = matchingAttribute.value.compactMap { status in
+ return PrivacyProSubscriptionStatus(rawValue: status)
}
- guard let status = PrivacyProSubscriptionStatus(rawValue: value) else {
- return .fail
+ for status in mappedStatuses {
+ switch status {
+ case .active: if isPrivacyProSubscriptionActive { return .match }
+ case .expiring: if isPrivacyProSubscriptionExpiring { return .match }
+ case .expired: if isPrivacyProSubscriptionExpired { return .match }
+ }
}
- switch status {
- case .active: return isPrivacyProSubscriptionActive ? .match : .fail
- case .expiring: return isPrivacyProSubscriptionExpiring ? .match : .fail
- case .expired: return isPrivacyProSubscriptionExpired ? .match : .fail
- }
+ return .fail
case let matchingAttribute as InteractedWithMessageMatchingAttribute:
if dismissedMessageIds.contains(where: { messageId in
StringArrayMatchingAttribute(matchingAttribute.value).matches(value: messageId) == .match
diff --git a/Sources/RemoteMessaging/Model/MatchingAttributes.swift b/Sources/RemoteMessaging/Model/MatchingAttributes.swift
index 62aef41aa..388d15995 100644
--- a/Sources/RemoteMessaging/Model/MatchingAttributes.swift
+++ b/Sources/RemoteMessaging/Model/MatchingAttributes.swift
@@ -799,13 +799,14 @@ struct PrivacyProPurchasePlatformMatchingAttribute: MatchingAttribute, Equatable
}
struct PrivacyProSubscriptionStatusMatchingAttribute: MatchingAttribute, Equatable {
- var value: String?
+ var value: [String] = []
var fallback: Bool?
init(jsonMatchingAttribute: AnyDecodable) {
- guard let jsonMatchingAttribute = jsonMatchingAttribute.value as? [String: Any] else { return }
-
- if let value = jsonMatchingAttribute[RuleAttributes.value] as? String {
+ guard let jsonMatchingAttribute = jsonMatchingAttribute.value as? [String: Any] else {
+ return
+ }
+ if let value = jsonMatchingAttribute[RuleAttributes.value] as? [String] {
self.value = value
}
if let fallback = jsonMatchingAttribute[RuleAttributes.fallback] as? Bool {
@@ -813,7 +814,7 @@ struct PrivacyProSubscriptionStatusMatchingAttribute: MatchingAttribute, Equatab
}
}
- init(value: String?, fallback: Bool?) {
+ init(value: [String], fallback: Bool?) {
self.value = value
self.fallback = fallback
}
diff --git a/Sources/Subscription/Services/APIService.swift b/Sources/Subscription/API/APIService.swift
similarity index 73%
rename from Sources/Subscription/Services/APIService.swift
rename to Sources/Subscription/API/APIService.swift
index b1f299676..647a21c48 100644
--- a/Sources/Subscription/Services/APIService.swift
+++ b/Sources/Subscription/API/APIService.swift
@@ -22,7 +22,7 @@ import Common
public enum APIServiceError: Swift.Error {
case decodingError
case encodingError
- case serverError(description: String)
+ case serverError(statusCode: Int, error: String?)
case unknownServerError
case connectionError
}
@@ -32,14 +32,26 @@ struct ErrorResponse: Decodable {
}
public protocol APIService {
- var baseURL: URL { get }
- var session: URLSession { get }
func executeAPICall(method: String, endpoint: String, headers: [String: String]?, body: Data?) async -> Result where T: Decodable
+ func makeAuthorizationHeader(for token: String) -> [String: String]
}
-public extension APIService {
+public enum APICachePolicy {
+ case reloadIgnoringLocalCacheData
+ case returnCacheDataElseLoad
+ case returnCacheDataDontLoad
+}
+
+public struct DefaultAPIService: APIService {
+ private let baseURL: URL
+ private let session: URLSession
+
+ public init(baseURL: URL, session: URLSession) {
+ self.baseURL = baseURL
+ self.session = session
+ }
- func executeAPICall(method: String, endpoint: String, headers: [String: String]? = nil, body: Data? = nil) async -> Result where T: Decodable {
+ public 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 {
@@ -47,7 +59,9 @@ public extension APIService {
printDebugInfo(method: method, endpoint: endpoint, data: data, response: urlResponse)
- if let httpResponse = urlResponse as? HTTPURLResponse, (200..<300).contains(httpResponse.statusCode) {
+ guard let httpResponse = urlResponse as? HTTPURLResponse else { return .failure(.unknownServerError) }
+
+ if (200..<300).contains(httpResponse.statusCode) {
if let decodedResponse = decode(T.self, from: data) {
return .success(decodedResponse)
} else {
@@ -55,14 +69,15 @@ public extension APIService {
return .failure(.decodingError)
}
} else {
+ var errorString: String?
+
if let decodedResponse = decode(ErrorResponse.self, from: data) {
- let errorDescription = "[\(endpoint)] \(urlResponse.httpStatusCodeAsString ?? ""): \(decodedResponse.error)"
- os_log(.error, log: .subscription, "Service error: %{public}@", errorDescription)
- return .failure(.serverError(description: errorDescription))
- } else {
- os_log(.error, log: .subscription, "Service error: APIServiceError.unknownServerError")
- return .failure(.unknownServerError)
+ errorString = decodedResponse.error
}
+
+ let errorLogMessage = "/\(endpoint) \(httpResponse.statusCode): \(errorString ?? "")"
+ os_log(.error, log: .subscription, "Service error: %{public}@", errorLogMessage)
+ return .failure(.serverError(statusCode: httpResponse.statusCode, error: errorString))
}
} catch {
os_log(.error, log: .subscription, "Service error: %{public}@", error.localizedDescription)
@@ -99,7 +114,7 @@ public extension APIService {
os_log(.info, log: .subscription, "[API] %d %{public}s /%{public}s :: %{public}s", statusCode, method, endpoint, stringData)
}
- func makeAuthorizationHeader(for token: String) -> [String: String] {
+ public func makeAuthorizationHeader(for token: String) -> [String: String] {
["Authorization": "Bearer " + token]
}
}
diff --git a/Sources/Subscription/API/AuthEndpointService.swift b/Sources/Subscription/API/AuthEndpointService.swift
new file mode 100644
index 000000000..31972404a
--- /dev/null
+++ b/Sources/Subscription/API/AuthEndpointService.swift
@@ -0,0 +1,110 @@
+//
+// AuthEndpointService.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 struct AccessTokenResponse: Decodable {
+ public let accessToken: String
+}
+
+public struct ValidateTokenResponse: Decodable {
+ public let account: Account
+
+ public struct Account: Decodable {
+ public let email: String?
+ public let entitlements: [Entitlement]
+ public let externalID: String
+
+ enum CodingKeys: String, CodingKey {
+ case email, entitlements, externalID = "externalId" // no underscores due to keyDecodingStrategy = .convertFromSnakeCase
+ }
+ }
+}
+
+public struct CreateAccountResponse: Decodable {
+ public let authToken: String
+ public let externalID: String
+ public let status: String
+
+ enum CodingKeys: String, CodingKey {
+ case authToken = "authToken", externalID = "externalId", status // no underscores due to keyDecodingStrategy = .convertFromSnakeCase
+ }
+}
+
+public struct StoreLoginResponse: Decodable {
+ public let authToken: String
+ public let email: String
+ public let externalID: String
+ public let id: Int
+ public let status: String
+
+ enum CodingKeys: String, CodingKey {
+ case authToken = "authToken", email, externalID = "externalId", id, status // no underscores due to keyDecodingStrategy = .convertFromSnakeCase
+ }
+}
+
+public protocol AuthEndpointService {
+ func getAccessToken(token: String) async -> Result
+ func validateToken(accessToken: String) async -> Result
+ func createAccount(emailAccessToken: String?) async -> Result
+ func storeLogin(signature: String) async -> Result
+}
+
+public struct DefaultAuthEndpointService: AuthEndpointService {
+ private let currentServiceEnvironment: SubscriptionEnvironment.ServiceEnvironment
+ private let apiService: APIService
+
+ public init(currentServiceEnvironment: SubscriptionEnvironment.ServiceEnvironment, apiService: APIService) {
+ self.currentServiceEnvironment = currentServiceEnvironment
+ self.apiService = apiService
+ }
+
+ public init(currentServiceEnvironment: SubscriptionEnvironment.ServiceEnvironment) {
+ self.currentServiceEnvironment = currentServiceEnvironment
+ let baseURL = currentServiceEnvironment == .production ? URL(string: "https://quack.duckduckgo.com/api/auth")! : URL(string: "https://quackdev.duckduckgo.com/api/auth")!
+ let session = URLSession(configuration: URLSessionConfiguration.ephemeral)
+ self.apiService = DefaultAPIService(baseURL: baseURL, session: session)
+ }
+
+ public func getAccessToken(token: String) async -> Result {
+ await apiService.executeAPICall(method: "GET", endpoint: "access-token", headers: apiService.makeAuthorizationHeader(for: token), body: nil)
+ }
+
+ public func validateToken(accessToken: String) async -> Result {
+ await apiService.executeAPICall(method: "GET", endpoint: "validate-token", headers: apiService.makeAuthorizationHeader(for: accessToken), body: nil)
+ }
+
+ public func createAccount(emailAccessToken: String?) async -> Result {
+ var headers: [String: String]?
+
+ if let emailAccessToken {
+ headers = apiService.makeAuthorizationHeader(for: emailAccessToken)
+ }
+
+ return await apiService.executeAPICall(method: "POST", endpoint: "account/create", headers: headers, body: nil)
+ }
+
+ public func storeLogin(signature: String) async -> Result {
+ let bodyDict = ["signature": signature,
+ "store": "apple_app_store"]
+
+ guard let bodyData = try? JSONEncoder().encode(bodyDict) else { return .failure(.encodingError) }
+ return await apiService.executeAPICall(method: "POST", endpoint: "store-login", headers: nil, body: bodyData)
+ }
+}
diff --git a/Sources/Subscription/Services/Model/Entitlement.swift b/Sources/Subscription/API/Model/Entitlement.swift
similarity index 98%
rename from Sources/Subscription/Services/Model/Entitlement.swift
rename to Sources/Subscription/API/Model/Entitlement.swift
index ece4b618a..c90e7342c 100644
--- a/Sources/Subscription/Services/Model/Entitlement.swift
+++ b/Sources/Subscription/API/Model/Entitlement.swift
@@ -19,7 +19,6 @@
import Foundation
public struct Entitlement: Codable, Equatable {
- let name: String
public let product: ProductName
public enum ProductName: String, Codable {
diff --git a/Sources/Subscription/Services/Model/Subscription.swift b/Sources/Subscription/API/Model/Subscription.swift
similarity index 100%
rename from Sources/Subscription/Services/Model/Subscription.swift
rename to Sources/Subscription/API/Model/Subscription.swift
diff --git a/Sources/Subscription/Services/SubscriptionService.swift b/Sources/Subscription/API/SubscriptionEndpointService.swift
similarity index 56%
rename from Sources/Subscription/Services/SubscriptionService.swift
rename to Sources/Subscription/API/SubscriptionEndpointService.swift
index 978eb3406..7898bbddb 100644
--- a/Sources/Subscription/Services/SubscriptionService.swift
+++ b/Sources/Subscription/API/SubscriptionEndpointService.swift
@@ -1,5 +1,5 @@
//
-// SubscriptionService.swift
+// SubscriptionEndpointService.swift
//
// Copyright © 2023 DuckDuckGo. All rights reserved.
//
@@ -19,48 +19,69 @@
import Common
import Foundation
-/// Communicates with our backend
-public final class SubscriptionService: APIService {
+public struct GetProductsItem: Decodable {
+ public let productId: String
+ public let productLabel: String
+ public let billingPeriod: String
+ public let price: String
+ public let currency: String
+}
- private let currentServiceEnvironment: SubscriptionEnvironment.ServiceEnvironment
+public struct GetCustomerPortalURLResponse: Decodable {
+ public let customerPortalUrl: String
+}
- public init(currentServiceEnvironment: SubscriptionEnvironment.ServiceEnvironment) {
- self.currentServiceEnvironment = currentServiceEnvironment
- }
+public struct ConfirmPurchaseResponse: Decodable {
+ public let email: String?
+ public let entitlements: [Entitlement]
+ public let subscription: Subscription
+}
- public let session = {
- let configuration = URLSessionConfiguration.ephemeral
- return URLSession(configuration: configuration)
- }()
-
- public var baseURL: URL {
- switch currentServiceEnvironment {
- case .production:
- URL(string: "https://subscriptions.duckduckgo.com/api")!
- case .staging:
- URL(string: "https://subscriptions-dev.duckduckgo.com/api")!
- }
+public enum SubscriptionServiceError: Error {
+ case noCachedData
+ case apiError(APIServiceError)
+}
+
+public protocol SubscriptionEndpointService {
+ func updateCache(with subscription: Subscription)
+ func getSubscription(accessToken: String, cachePolicy: APICachePolicy) async -> Result
+ func signOut()
+ func getProducts() async -> Result<[GetProductsItem], APIServiceError>
+ func getCustomerPortalURL(accessToken: String, externalID: String) async -> Result
+ func confirmPurchase(accessToken: String, signature: String) async -> Result
+}
+
+extension SubscriptionEndpointService {
+
+ public func getSubscription(accessToken: String) async -> Result {
+ await getSubscription(accessToken: accessToken, cachePolicy: .returnCacheDataElseLoad)
}
+}
+/// Communicates with our backend
+public struct DefaultSubscriptionEndpointService: SubscriptionEndpointService {
+ private let currentServiceEnvironment: SubscriptionEnvironment.ServiceEnvironment
+ private let apiService: APIService
private let subscriptionCache = UserDefaultsCache(key: UserDefaultsCacheKey.subscription,
settings: UserDefaultsCacheSettings(defaultExpirationInterval: .minutes(20)))
- public enum CachePolicy {
- case reloadIgnoringLocalCacheData
- case returnCacheDataElseLoad
- case returnCacheDataDontLoad
+ public init(currentServiceEnvironment: SubscriptionEnvironment.ServiceEnvironment, apiService: APIService) {
+ self.currentServiceEnvironment = currentServiceEnvironment
+ self.apiService = apiService
}
- public enum SubscriptionServiceError: Error {
- case noCachedData
- case apiError(APIServiceError)
+ public init(currentServiceEnvironment: SubscriptionEnvironment.ServiceEnvironment) {
+ self.currentServiceEnvironment = currentServiceEnvironment
+ let baseURL = currentServiceEnvironment == .production ? URL(string: "https://subscriptions.duckduckgo.com/api")! : URL(string: "https://subscriptions-dev.duckduckgo.com/api")!
+ let session = URLSession(configuration: URLSessionConfiguration.ephemeral)
+ self.apiService = DefaultAPIService(baseURL: baseURL, session: session)
}
// MARK: - Subscription fetching with caching
private func getRemoteSubscription(accessToken: String) async -> Result {
- let result: Result = await executeAPICall(method: "GET", endpoint: "subscription", headers: makeAuthorizationHeader(for: accessToken))
+ let result: Result = await apiService.executeAPICall(method: "GET", endpoint: "subscription", headers: apiService.makeAuthorizationHeader(for: accessToken), body: nil)
switch result {
case .success(let subscriptionResponse):
updateCache(with: subscriptionResponse)
@@ -82,7 +103,7 @@ public final class SubscriptionService: APIService {
}
}
- public func getSubscription(accessToken: String, cachePolicy: CachePolicy = .returnCacheDataElseLoad) async -> Result {
+ public func getSubscription(accessToken: String, cachePolicy: APICachePolicy = .returnCacheDataElseLoad) async -> Result {
switch cachePolicy {
case .reloadIgnoringLocalCacheData:
@@ -111,42 +132,24 @@ public final class SubscriptionService: APIService {
// MARK: -
public func getProducts() async -> Result<[GetProductsItem], APIServiceError> {
- await executeAPICall(method: "GET", endpoint: "products")
- }
-
- public struct GetProductsItem: Decodable {
- public let productId: String
- public let productLabel: String
- public let billingPeriod: String
- public let price: String
- public let currency: String
+ await apiService.executeAPICall(method: "GET", endpoint: "products", headers: nil, body: nil)
}
// MARK: -
public func getCustomerPortalURL(accessToken: String, externalID: String) async -> Result {
- var headers = makeAuthorizationHeader(for: accessToken)
+ var headers = apiService.makeAuthorizationHeader(for: accessToken)
headers["externalAccountId"] = externalID
- return await executeAPICall(method: "GET", endpoint: "checkout/portal", headers: headers)
- }
-
- public struct GetCustomerPortalURLResponse: Decodable {
- public let customerPortalUrl: String
+ return await apiService.executeAPICall(method: "GET", endpoint: "checkout/portal", headers: headers, body: nil)
}
// MARK: -
public func confirmPurchase(accessToken: String, signature: String) async -> Result {
- let headers = makeAuthorizationHeader(for: accessToken)
+ let headers = apiService.makeAuthorizationHeader(for: accessToken)
let bodyDict = ["signedTransactionInfo": signature]
guard let bodyData = try? JSONEncoder().encode(bodyDict) else { return .failure(.encodingError) }
- return await executeAPICall(method: "POST", endpoint: "purchase/confirm/apple", headers: headers, body: bodyData)
- }
-
- public struct ConfirmPurchaseResponse: Decodable {
- public let email: String?
- public let entitlements: [Entitlement]
- public let subscription: Subscription
+ return await apiService.executeAPICall(method: "POST", endpoint: "purchase/confirm/apple", headers: headers, body: bodyData)
}
}
diff --git a/Sources/Subscription/AccountStorage/AccountKeychainStorage.swift b/Sources/Subscription/AccountStorage/AccountKeychainStorage.swift
index 4cc23a70d..5faddd81e 100644
--- a/Sources/Subscription/AccountStorage/AccountKeychainStorage.swift
+++ b/Sources/Subscription/AccountStorage/AccountKeychainStorage.swift
@@ -48,7 +48,7 @@ public enum AccountKeychainAccessError: Error, Equatable {
}
}
-public class AccountKeychainStorage: AccountStoring {
+public final class AccountKeychainStorage: AccountStoring {
public init() {}
diff --git a/Sources/Subscription/AccountStorage/SubscriptionTokenKeychainStorage.swift b/Sources/Subscription/AccountStorage/SubscriptionTokenKeychainStorage.swift
index 40f6442b8..3e5772a44 100644
--- a/Sources/Subscription/AccountStorage/SubscriptionTokenKeychainStorage.swift
+++ b/Sources/Subscription/AccountStorage/SubscriptionTokenKeychainStorage.swift
@@ -18,7 +18,7 @@
import Foundation
-public class SubscriptionTokenKeychainStorage: SubscriptionTokenStoring {
+public final class SubscriptionTokenKeychainStorage: SubscriptionTokenStoring {
private let keychainType: KeychainType
diff --git a/Sources/Subscription/Flows/AppStore/AppStoreAccountManagementFlow.swift b/Sources/Subscription/Flows/AppStore/AppStoreAccountManagementFlow.swift
index ab3cffe46..acbf4ab82 100644
--- a/Sources/Subscription/Flows/AppStore/AppStoreAccountManagementFlow.swift
+++ b/Sources/Subscription/Flows/AppStore/AppStoreAccountManagementFlow.swift
@@ -20,36 +20,42 @@ import Foundation
import StoreKit
import Common
+public enum AppStoreAccountManagementFlowError: Swift.Error {
+ case noPastTransaction
+ case authenticatingWithTransactionFailed
+}
+
@available(macOS 12.0, iOS 15.0, *)
-public final class AppStoreAccountManagementFlow {
+public protocol AppStoreAccountManagementFlow {
+ @discardableResult func refreshAuthTokenIfNeeded() async -> Result
+}
- private let subscriptionManager: SubscriptionManaging
- private var accountManager: AccountManaging {
- subscriptionManager.accountManager
- }
+@available(macOS 12.0, iOS 15.0, *)
+public final class DefaultAppStoreAccountManagementFlow: AppStoreAccountManagementFlow {
- public init(subscriptionManager: SubscriptionManaging) {
- self.subscriptionManager = subscriptionManager
- }
+ private let authEndpointService: AuthEndpointService
+ private let storePurchaseManager: StorePurchaseManager
+ private let accountManager: AccountManager
- public enum Error: Swift.Error {
- case noPastTransaction
- case authenticatingWithTransactionFailed
+ public init(authEndpointService: any AuthEndpointService, storePurchaseManager: any StorePurchaseManager, accountManager: any AccountManager) {
+ self.authEndpointService = authEndpointService
+ self.storePurchaseManager = storePurchaseManager
+ self.accountManager = accountManager
}
@discardableResult
- public func refreshAuthTokenIfNeeded() async -> Result {
+ public func refreshAuthTokenIfNeeded() async -> Result {
os_log(.info, log: .subscription, "[AppStoreAccountManagementFlow] refreshAuthTokenIfNeeded")
var authToken = accountManager.authToken ?? ""
// Check if auth token if still valid
- if case let .failure(validateTokenError) = await subscriptionManager.authService.validateToken(accessToken: authToken) {
+ if case let .failure(validateTokenError) = await authEndpointService.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 subscriptionManager.storePurchaseManager().mostRecentTransaction() else { return .failure(.noPastTransaction) }
+ guard let lastTransactionJWSRepresentation = await storePurchaseManager.mostRecentTransaction() else { return .failure(.noPastTransaction) }
- switch await subscriptionManager.authService.storeLogin(signature: lastTransactionJWSRepresentation) {
+ switch await authEndpointService.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 e3248a9f7..ff35bbcd0 100644
--- a/Sources/Subscription/Flows/AppStore/AppStorePurchaseFlow.swift
+++ b/Sources/Subscription/Flows/AppStore/AppStorePurchaseFlow.swift
@@ -20,33 +20,47 @@ import Foundation
import StoreKit
import Common
-@available(macOS 12.0, iOS 15.0, *)
-public final class AppStorePurchaseFlow {
-
- public enum Error: Swift.Error {
- case noProductsFound
- case activeSubscriptionAlreadyPresent
- case authenticatingWithTransactionFailed
- case accountCreationFailed
- case purchaseFailed
- case cancelledByUser
- case missingEntitlements
- case internalError
- }
+public enum AppStorePurchaseFlowError: Swift.Error {
+ case noProductsFound
+ case activeSubscriptionAlreadyPresent
+ case authenticatingWithTransactionFailed
+ case accountCreationFailed
+ case purchaseFailed
+ case cancelledByUser
+ case missingEntitlements
+ case internalError
+}
- private let subscriptionManager: SubscriptionManaging
- var accountManager: AccountManaging {
- subscriptionManager.accountManager
- }
+@available(macOS 12.0, iOS 15.0, *)
+public protocol AppStorePurchaseFlow {
+ typealias TransactionJWS = String
+ func purchaseSubscription(with subscriptionIdentifier: String, emailAccessToken: String?) async -> Result
+ @discardableResult
+ func completeSubscriptionPurchase(with transactionJWS: AppStorePurchaseFlow.TransactionJWS) async -> Result
+}
- public init(subscriptionManager: SubscriptionManaging) {
- self.subscriptionManager = subscriptionManager
+@available(macOS 12.0, iOS 15.0, *)
+public final class DefaultAppStorePurchaseFlow: AppStorePurchaseFlow {
+ private let subscriptionEndpointService: SubscriptionEndpointService
+ private let storePurchaseManager: StorePurchaseManager
+ private let accountManager: AccountManager
+ private let appStoreRestoreFlow: AppStoreRestoreFlow
+ private let authEndpointService: AuthEndpointService
+
+ public init(subscriptionEndpointService: any SubscriptionEndpointService,
+ storePurchaseManager: any StorePurchaseManager,
+ accountManager: any AccountManager,
+ appStoreRestoreFlow: any AppStoreRestoreFlow,
+ authEndpointService: any AuthEndpointService) {
+ self.subscriptionEndpointService = subscriptionEndpointService
+ self.storePurchaseManager = storePurchaseManager
+ self.accountManager = accountManager
+ self.appStoreRestoreFlow = appStoreRestoreFlow
+ self.authEndpointService = authEndpointService
}
- public typealias TransactionJWS = String
-
// swiftlint:disable cyclomatic_complexity
- public func purchaseSubscription(with subscriptionIdentifier: String, emailAccessToken: String?) async -> Result {
+ public func purchaseSubscription(with subscriptionIdentifier: String, emailAccessToken: String?) async -> Result {
os_log(.info, log: .subscription, "[AppStorePurchaseFlow] purchaseSubscription")
let externalID: String
@@ -57,7 +71,6 @@ public final class AppStorePurchaseFlow {
// Otherwise, try to retrieve an expired Apple subscription or create a new one
} else {
// Check for past transactions most recent
- let appStoreRestoreFlow = AppStoreRestoreFlow(subscriptionManager: subscriptionManager)
switch await appStoreRestoreFlow.restoreAccountFromPastPurchase() {
case .success:
os_log(.info, log: .subscription, "[AppStorePurchaseFlow] purchaseSubscription -> restoreAccountFromPastPurchase: activeSubscriptionAlreadyPresent")
@@ -70,7 +83,7 @@ public final class AppStorePurchaseFlow {
accountManager.storeAuthToken(token: expiredAccountDetails.authToken)
accountManager.storeAccount(token: expiredAccountDetails.accessToken, email: expiredAccountDetails.email, externalID: expiredAccountDetails.externalID)
default:
- switch await subscriptionManager.authService.createAccount(emailAccessToken: emailAccessToken) {
+ switch await authEndpointService.createAccount(emailAccessToken: emailAccessToken) {
case .success(let response):
externalID = response.externalID
@@ -88,7 +101,7 @@ public final class AppStorePurchaseFlow {
}
// Make the purchase
- switch await subscriptionManager.storePurchaseManager().purchaseSubscription(with: subscriptionIdentifier, externalID: externalID) {
+ switch await storePurchaseManager.purchaseSubscription(with: subscriptionIdentifier, externalID: externalID) {
case .success(let transactionJWS):
return .success(transactionJWS)
case .failure(let error):
@@ -105,19 +118,19 @@ public final class AppStorePurchaseFlow {
// swiftlint:enable cyclomatic_complexity
@discardableResult
- public func completeSubscriptionPurchase(with transactionJWS: TransactionJWS) async -> Result {
+ public func completeSubscriptionPurchase(with transactionJWS: TransactionJWS) async -> Result {
// Clear subscription Cache
- subscriptionManager.subscriptionService.signOut()
+ subscriptionEndpointService.signOut()
os_log(.info, log: .subscription, "[AppStorePurchaseFlow] completeSubscriptionPurchase")
guard let accessToken = accountManager.accessToken else { return .failure(.missingEntitlements) }
let result = await callWithRetries(retry: 5, wait: 2.0) {
- switch await subscriptionManager.subscriptionService.confirmPurchase(accessToken: accessToken, signature: transactionJWS) {
+ switch await subscriptionEndpointService.confirmPurchase(accessToken: accessToken, signature: transactionJWS) {
case .success(let confirmation):
- subscriptionManager.subscriptionService.updateCache(with: confirmation.subscription)
+ subscriptionEndpointService.updateCache(with: confirmation.subscription)
accountManager.updateCache(with: confirmation.entitlements)
return true
case .failure:
@@ -152,7 +165,7 @@ public final class AppStorePurchaseFlow {
let token = accountManager.accessToken
else { return nil }
- let subscriptionInfo = await subscriptionManager.subscriptionService.getSubscription(accessToken: token, cachePolicy: .reloadIgnoringLocalCacheData)
+ let subscriptionInfo = await subscriptionEndpointService.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 c09afb7e1..15a8b5c94 100644
--- a/Sources/Subscription/Flows/AppStore/AppStoreRestoreFlow.swift
+++ b/Sources/Subscription/Flows/AppStore/AppStoreRestoreFlow.swift
@@ -20,38 +20,48 @@ import Foundation
import StoreKit
import Common
-@available(macOS 12.0, iOS 15.0, *)
-public final class AppStoreRestoreFlow {
-
- public typealias RestoredAccountDetails = (authToken: String, accessToken: String, externalID: String, email: String?)
-
- public enum Error: Swift.Error {
- case missingAccountOrTransactions
- case pastTransactionAuthenticationError
- case failedToObtainAccessToken
- case failedToFetchAccountDetails
- case failedToFetchSubscriptionDetails
- case subscriptionExpired(accountDetails: RestoredAccountDetails)
- }
+public typealias RestoredAccountDetails = (authToken: String, accessToken: String, externalID: String, email: String?)
+
+public enum AppStoreRestoreFlowError: Swift.Error {
+ case missingAccountOrTransactions
+ case pastTransactionAuthenticationError
+ case failedToObtainAccessToken
+ case failedToFetchAccountDetails
+ case failedToFetchSubscriptionDetails
+ case subscriptionExpired(accountDetails: RestoredAccountDetails)
+}
- private let subscriptionManager: SubscriptionManaging
- var accountManager: AccountManaging {
- subscriptionManager.accountManager
- }
+@available(macOS 12.0, iOS 15.0, *)
+public protocol AppStoreRestoreFlow {
+ @discardableResult func restoreAccountFromPastPurchase() async -> Result
+}
- public init(subscriptionManager: SubscriptionManaging) {
- self.subscriptionManager = subscriptionManager
+@available(macOS 12.0, iOS 15.0, *)
+public final class DefaultAppStoreRestoreFlow: AppStoreRestoreFlow {
+ private let accountManager: AccountManager
+ private let storePurchaseManager: StorePurchaseManager
+ private let subscriptionEndpointService: SubscriptionEndpointService
+ private let authEndpointService: AuthEndpointService
+
+ public init(accountManager: any AccountManager,
+ storePurchaseManager: any StorePurchaseManager,
+ subscriptionEndpointService: any SubscriptionEndpointService,
+ authEndpointService: any AuthEndpointService) {
+ self.accountManager = accountManager
+ self.storePurchaseManager = storePurchaseManager
+ self.subscriptionEndpointService = subscriptionEndpointService
+ self.authEndpointService = authEndpointService
}
@discardableResult
- public func restoreAccountFromPastPurchase() async -> Result {
+ public func restoreAccountFromPastPurchase() async -> Result {
// Clear subscription Cache
- subscriptionManager.subscriptionService.signOut()
+ subscriptionEndpointService.signOut()
os_log(.info, log: .subscription, "[AppStoreRestoreFlow] restoreAccountFromPastPurchase")
- guard let lastTransactionJWSRepresentation = await subscriptionManager.storePurchaseManager().mostRecentTransaction() else {
+ guard let lastTransactionJWSRepresentation = await storePurchaseManager.mostRecentTransaction() else {
os_log(.error, log: .subscription, "[AppStoreRestoreFlow] Error: missingAccountOrTransactions")
return .failure(.missingAccountOrTransactions)
}
@@ -59,7 +69,7 @@ public final class AppStoreRestoreFlow {
// Do the store login to get short-lived token
let authToken: String
- switch await subscriptionManager.authService.storeLogin(signature: lastTransactionJWSRepresentation) {
+ switch await authEndpointService.storeLogin(signature: lastTransactionJWSRepresentation) {
case .success(let response):
authToken = response.authToken
case .failure:
@@ -90,7 +100,7 @@ public final class AppStoreRestoreFlow {
var isSubscriptionActive = false
- switch await subscriptionManager.subscriptionService.getSubscription(accessToken: accessToken, cachePolicy: .reloadIgnoringLocalCacheData) {
+ switch await subscriptionEndpointService.getSubscription(accessToken: accessToken, cachePolicy: .reloadIgnoringLocalCacheData) {
case .success(let subscription):
isSubscriptionActive = subscription.isActive
case .failure:
diff --git a/Sources/Subscription/Flows/Stripe/StripePurchaseFlow.swift b/Sources/Subscription/Flows/Stripe/StripePurchaseFlow.swift
index 24c3021ee..2e561882a 100644
--- a/Sources/Subscription/Flows/Stripe/StripePurchaseFlow.swift
+++ b/Sources/Subscription/Flows/Stripe/StripePurchaseFlow.swift
@@ -20,26 +20,34 @@ import Foundation
import StoreKit
import Common
-public final class StripePurchaseFlow {
-
- private let subscriptionManager: SubscriptionManaging
- var accountManager: AccountManaging {
- subscriptionManager.accountManager
- }
+public enum StripePurchaseFlowError: Swift.Error {
+ case noProductsFound
+ case accountCreationFailed
+}
- public init(subscriptionManager: SubscriptionManaging) {
- self.subscriptionManager = subscriptionManager
- }
+public protocol StripePurchaseFlow {
+ func subscriptionOptions() async -> Result
+ func prepareSubscriptionPurchase(emailAccessToken: String?) async -> Result
+ func completeSubscriptionPurchase() async
+}
- public enum Error: Swift.Error {
- case noProductsFound
- case accountCreationFailed
+public final class DefaultStripePurchaseFlow: StripePurchaseFlow {
+ private let subscriptionEndpointService: SubscriptionEndpointService
+ private let authEndpointService: AuthEndpointService
+ private let accountManager: AccountManager
+
+ public init(subscriptionEndpointService: any SubscriptionEndpointService,
+ authEndpointService: any AuthEndpointService,
+ accountManager: any AccountManager) {
+ self.subscriptionEndpointService = subscriptionEndpointService
+ self.authEndpointService = authEndpointService
+ self.accountManager = accountManager
}
- public func subscriptionOptions() async -> Result {
+ public func subscriptionOptions() async -> Result {
os_log(.info, log: .subscription, "[StripePurchaseFlow] subscriptionOptions")
- guard case let .success(products) = await subscriptionManager.subscriptionService.getProducts(), !products.isEmpty else {
+ guard case let .success(products) = await subscriptionEndpointService.getProducts(), !products.isEmpty else {
os_log(.error, log: .subscription, "[StripePurchaseFlow] Error: noProductsFound")
return .failure(.noProductsFound)
}
@@ -70,11 +78,11 @@ public final class StripePurchaseFlow {
features: features))
}
- public func prepareSubscriptionPurchase(emailAccessToken: String?) async -> Result {
+ public func prepareSubscriptionPurchase(emailAccessToken: String?) async -> Result {
os_log(.info, log: .subscription, "[StripePurchaseFlow] prepareSubscriptionPurchase")
// Clear subscription Cache
- subscriptionManager.subscriptionService.signOut()
+ subscriptionEndpointService.signOut()
var token: String = ""
if let accessToken = accountManager.accessToken {
@@ -82,7 +90,7 @@ public final class StripePurchaseFlow {
token = accessToken
}
} else {
- switch await subscriptionManager.authService.createAccount(emailAccessToken: emailAccessToken) {
+ switch await authEndpointService.createAccount(emailAccessToken: emailAccessToken) {
case .success(let response):
token = response.authToken
accountManager.storeAuthToken(token: token)
@@ -96,7 +104,7 @@ public final class StripePurchaseFlow {
}
private func isSubscriptionExpired(accessToken: String) async -> Bool {
- if case .success(let subscription) = await subscriptionManager.subscriptionService.getSubscription(accessToken: accessToken) {
+ if case .success(let subscription) = await subscriptionEndpointService.getSubscription(accessToken: accessToken) {
return !subscription.isActive
}
@@ -104,9 +112,8 @@ public final class StripePurchaseFlow {
}
public func completeSubscriptionPurchase() async {
-
// Clear subscription Cache
- subscriptionManager.subscriptionService.signOut()
+ subscriptionEndpointService.signOut()
os_log(.info, log: .subscription, "[StripePurchaseFlow] completeSubscriptionPurchase")
if !accountManager.isUserAuthenticated,
diff --git a/Sources/Subscription/SubManagers/AccountManager.swift b/Sources/Subscription/Managers/AccountManager.swift
similarity index 79%
rename from Sources/Subscription/SubManagers/AccountManager.swift
rename to Sources/Subscription/Managers/AccountManager.swift
index 7055005fc..caf5e8db1 100644
--- a/Sources/Subscription/SubManagers/AccountManager.swift
+++ b/Sources/Subscription/Managers/AccountManager.swift
@@ -19,29 +19,72 @@
import Foundation
import Common
-public class AccountManager: AccountManaging {
+public protocol AccountManagerKeychainAccessDelegate: AnyObject {
+ func accountManagerKeychainAccessFailed(accessType: AccountKeychainAccessType, error: AccountKeychainAccessError)
+}
+
+public protocol AccountManager {
+
+ var delegate: AccountManagerKeychainAccessDelegate? { get set }
+ 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
+ func hasEntitlement(forProductName productName: Entitlement.ProductName, cachePolicy: APICachePolicy) async -> Result
+
+ func updateCache(with entitlements: [Entitlement])
+ @discardableResult func fetchEntitlements(cachePolicy: APICachePolicy) async -> Result<[Entitlement], Error>
+ func exchangeAuthTokenToAccessToken(_ authToken: String) async -> Result
+
+ typealias AccountDetails = (email: String?, externalID: String)
+ func fetchAccountDetails(with accessToken: String) async -> Result
+
+ @discardableResult func checkForEntitlements(wait waitTime: Double, retry retryCount: Int) async -> Bool
+}
+
+extension AccountManager {
+
+ public func hasEntitlement(forProductName productName: Entitlement.ProductName) async -> Result {
+ await hasEntitlement(forProductName: productName, cachePolicy: .returnCacheDataElseLoad)
+ }
+
+ public func fetchEntitlements() async -> Result<[Entitlement], Error> {
+ await fetchEntitlements(cachePolicy: .returnCacheDataElseLoad)
+ }
+
+ public var isUserAuthenticated: Bool { accessToken != nil }
+}
+
+public final class DefaultAccountManager: AccountManager {
private let storage: AccountStoring
private let entitlementsCache: UserDefaultsCache<[Entitlement]>
private let accessTokenStorage: SubscriptionTokenStoring
- private let subscriptionService: SubscriptionService
- private let authService: AuthService
+ private let subscriptionEndpointService: SubscriptionEndpointService
+ private let authEndpointService: AuthEndpointService
public weak var delegate: AccountManagerKeychainAccessDelegate?
- public var isUserAuthenticated: Bool { accessToken != nil }
// MARK: - Initialisers
public init(storage: AccountStoring = AccountKeychainStorage(),
accessTokenStorage: SubscriptionTokenStoring,
entitlementsCache: UserDefaultsCache<[Entitlement]>,
- subscriptionService: SubscriptionService,
- authService: AuthService) {
+ subscriptionEndpointService: SubscriptionEndpointService,
+ authEndpointService: AuthEndpointService) {
self.storage = storage
self.entitlementsCache = entitlementsCache
self.accessTokenStorage = accessTokenStorage
- self.subscriptionService = subscriptionService
- self.authService = authService
+ self.subscriptionEndpointService = subscriptionEndpointService
+ self.authEndpointService = authEndpointService
}
// MARK: -
@@ -161,7 +204,7 @@ public class AccountManager: AccountManaging {
do {
try storage.clearAuthenticationState()
try accessTokenStorage.removeAccessToken()
- subscriptionService.signOut()
+ subscriptionEndpointService.signOut()
entitlementsCache.reset()
} catch {
if let error = error as? AccountKeychainAccessError {
@@ -199,32 +242,22 @@ public class AccountManager: AccountManaging {
}
// MARK: -
-
- public enum EntitlementsError: Error {
- case noAccessToken
- case noCachedData
- }
-
- public func hasEntitlement(for entitlement: Entitlement.ProductName, cachePolicy: CachePolicy = .returnCacheDataElseLoad) async -> Result {
+ public func hasEntitlement(forProductName productName: Entitlement.ProductName, cachePolicy: APICachePolicy) async -> Result {
switch await fetchEntitlements(cachePolicy: cachePolicy) {
case .success(let entitlements):
- return .success(entitlements.compactMap { $0.product }.contains(entitlement))
+ return .success(entitlements.compactMap { $0.product }.contains(productName))
case .failure(let error):
return .failure(error)
}
}
- 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 authEndpointService.validateToken(accessToken: accessToken) {
case .success(let response):
let entitlements = response.account.entitlements
updateCache(with: entitlements)
@@ -249,8 +282,13 @@ public class AccountManager: AccountManaging {
}
}
+ public enum EntitlementsError: Error {
+ case noAccessToken
+ case noCachedData
+ }
+
@discardableResult
- public func fetchEntitlements(cachePolicy: CachePolicy = .returnCacheDataElseLoad) async -> Result<[Entitlement], Error> {
+ public func fetchEntitlements(cachePolicy: APICachePolicy) async -> Result<[Entitlement], Error> {
switch cachePolicy {
case .reloadIgnoringLocalCacheData:
@@ -274,7 +312,7 @@ public class AccountManager: AccountManaging {
}
public func exchangeAuthTokenToAccessToken(_ authToken: String) async -> Result {
- switch await authService.getAccessToken(token: authToken) {
+ switch await authEndpointService.getAccessToken(token: authToken) {
case .success(let response):
return .success(response.accessToken)
case .failure(let error):
@@ -284,7 +322,7 @@ public class AccountManager: AccountManaging {
}
public func fetchAccountDetails(with accessToken: String) async -> Result {
- switch await authService.validateToken(accessToken: accessToken) {
+ switch await authEndpointService.validateToken(accessToken: accessToken) {
case .success(let response):
return .success(AccountDetails(email: response.account.email, externalID: response.account.externalID))
case .failure(let error):
@@ -293,24 +331,6 @@ public class AccountManager: AccountManaging {
}
}
- public func refreshSubscriptionAndEntitlements() async {
- os_log(.info, log: .subscription, "[AccountManager] refreshSubscriptionAndEntitlements")
-
- guard let token = accessToken else {
- subscriptionService.signOut()
- entitlementsCache.reset()
- return
- }
-
- if case .success(let subscription) = await subscriptionService.getSubscription(accessToken: token, cachePolicy: .reloadIgnoringLocalCacheData) {
- if !subscription.isActive {
- signOut()
- }
- }
-
- await fetchEntitlements(cachePolicy: .reloadIgnoringLocalCacheData)
- }
-
@discardableResult
public func checkForEntitlements(wait waitTime: Double, retry retryCount: Int) async -> Bool {
var count = 0
diff --git a/Sources/Subscription/SubManagers/StorePurchaseManager.swift b/Sources/Subscription/Managers/StorePurchaseManager.swift
similarity index 84%
rename from Sources/Subscription/SubManagers/StorePurchaseManager.swift
rename to Sources/Subscription/Managers/StorePurchaseManager.swift
index d475a91df..25f550d8c 100644
--- a/Sources/Subscription/SubManagers/StorePurchaseManager.swift
+++ b/Sources/Subscription/Managers/StorePurchaseManager.swift
@@ -20,12 +20,40 @@ import Foundation
import StoreKit
import Common
+public enum StoreError: Error {
+ case failedVerification
+}
+
+public enum StorePurchaseManagerError: Error {
+ case productNotFound
+ case externalIDisNotAValidUUID
+ case purchaseFailed
+ case transactionCannotBeVerified
+ case transactionPendingAuthentication
+ case purchaseCancelledByUser
+ case unknownError
+}
+
+public protocol StorePurchaseManager {
+ typealias TransactionJWS = String
+ func subscriptionOptions() async -> SubscriptionOptions?
+ var purchasedProductIDs: [String] { get }
+ var purchaseQueue: [String] { get }
+ var areProductsAvailable: Bool { get }
+ @MainActor func syncAppleIDAccount() async throws
+ @MainActor func updateAvailableProducts() async
+ @MainActor func updatePurchasedProducts() async
+ @MainActor func mostRecentTransaction() async -> String?
+ @MainActor func hasActiveSubscription() async -> Bool
+ @MainActor func purchaseSubscription(with identifier: String, externalID: String) async -> Result
+}
+
@available(macOS 12.0, iOS 15.0, *) typealias Transaction = StoreKit.Transaction
@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
@available(macOS 12.0, iOS 15.0, *)
-public final class StorePurchaseManager: ObservableObject, StorePurchaseManaging {
+public final class DefaultStorePurchaseManager: ObservableObject, StorePurchaseManager {
let productIdentifiers = ["ios.subscription.1month", "ios.subscription.1year",
"subscription.1month", "subscription.1year",
@@ -38,10 +66,7 @@ public final class StorePurchaseManager: ObservableObject, StorePurchaseManaging
@Published public private(set) var purchaseQueue: [String] = []
@Published private var subscriptionGroupStatus: RenewalState?
- public var areProductsAvailable: Bool {
- !availableProducts.isEmpty
- }
-
+ public var areProductsAvailable: Bool { !availableProducts.isEmpty }
private var transactionUpdates: Task?
private var storefrontChanges: Task?
@@ -56,8 +81,7 @@ public final class StorePurchaseManager: ObservableObject, StorePurchaseManaging
}
@MainActor
- @discardableResult
- public func syncAppleIDAccount() async -> Result {
+ public func syncAppleIDAccount() async throws {
do {
purchaseQueue.removeAll()
@@ -69,11 +93,9 @@ public final class StorePurchaseManager: ObservableObject, StorePurchaseManaging
await updatePurchasedProducts()
await updateAvailableProducts()
-
- return .success(())
} catch {
os_log(.error, log: .subscription, "[StorePurchaseManager] Error: %{public}s (%{public}s)", String(reflecting: error), error.localizedDescription)
- return .failure(error)
+ throw error
}
}
@@ -178,12 +200,10 @@ public final class StorePurchaseManager: ObservableObject, StorePurchaseManaging
return !transactions.isEmpty
}
- public typealias TransactionJWS = String
-
@MainActor
- public func purchaseSubscription(with identifier: String, externalID: String) async -> Result {
+ public func purchaseSubscription(with identifier: String, externalID: String) async -> Result {
- guard let product = availableProducts.first(where: { $0.id == identifier }) else { return .failure(PurchaseManagerError.productNotFound) }
+ guard let product = availableProducts.first(where: { $0.id == identifier }) else { return .failure(StorePurchaseManagerError.productNotFound) }
os_log(.info, log: .subscription, "[StorePurchaseManager] purchaseSubscription %{public}s (%{public}s)", product.displayName, externalID)
@@ -195,7 +215,7 @@ public final class StorePurchaseManager: ObservableObject, StorePurchaseManaging
options.insert(.appAccountToken(token))
} else {
os_log(.error, log: .subscription, "[StorePurchaseManager] Error: Failed to create UUID")
- return .failure(PurchaseManagerError.externalIDisNotAValidUUID)
+ return .failure(StorePurchaseManagerError.externalIDisNotAValidUUID)
}
let purchaseResult: Product.PurchaseResult
@@ -203,7 +223,7 @@ public final class StorePurchaseManager: ObservableObject, StorePurchaseManaging
purchaseResult = try await product.purchase(options: options)
} catch {
os_log(.error, log: .subscription, "[StorePurchaseManager] Error: %{public}s", String(reflecting: error))
- return .failure(PurchaseManagerError.purchaseFailed)
+ return .failure(StorePurchaseManagerError.purchaseFailed)
}
os_log(.info, log: .subscription, "[StorePurchaseManager] purchaseSubscription complete")
@@ -223,19 +243,19 @@ public final class StorePurchaseManager: ObservableObject, StorePurchaseManaging
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)
+ return .failure(StorePurchaseManagerError.transactionCannotBeVerified)
}
case .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)
+ return .failure(StorePurchaseManagerError.transactionPendingAuthentication)
case .userCancelled:
os_log(.info, log: .subscription, "[StorePurchaseManager] purchaseSubscription result: user cancelled")
- return .failure(PurchaseManagerError.purchaseCancelledByUser)
+ return .failure(StorePurchaseManagerError.purchaseCancelledByUser)
@unknown default:
os_log(.info, log: .subscription, "[StorePurchaseManager] purchaseSubscription result: unknown")
- return .failure(PurchaseManagerError.unknownError)
+ return .failure(StorePurchaseManagerError.unknownError)
}
}
diff --git a/Sources/Subscription/SubscriptionManager.swift b/Sources/Subscription/Managers/SubscriptionManager.swift
similarity index 59%
rename from Sources/Subscription/SubscriptionManager.swift
rename to Sources/Subscription/Managers/SubscriptionManager.swift
index 96d1806b9..c85b80224 100644
--- a/Sources/Subscription/SubscriptionManager.swift
+++ b/Sources/Subscription/Managers/SubscriptionManager.swift
@@ -19,39 +19,42 @@
import Foundation
import Common
-public protocol SubscriptionManaging {
-
- var accountManager: AccountManaging { get }
- var subscriptionService: SubscriptionService { get }
- var authService: AuthService { get }
+public protocol SubscriptionManager {
+ // Dependencies
+ var accountManager: AccountManager { get }
+ var subscriptionEndpointService: SubscriptionEndpointService { get }
+ var authEndpointService: AuthEndpointService { get }
+
+ // Environment
+ static func loadEnvironmentFrom(userDefaults: UserDefaults) -> SubscriptionEnvironment?
+ static func save(subscriptionEnvironment: SubscriptionEnvironment, userDefaults: UserDefaults)
var currentEnvironment: SubscriptionEnvironment { get }
+
var canPurchase: Bool { get }
- @available(macOS 12.0, iOS 15.0, *) func storePurchaseManager() -> StorePurchaseManaging
+ @available(macOS 12.0, iOS 15.0, *) func storePurchaseManager() -> StorePurchaseManager
func loadInitialData()
- func updateSubscriptionStatus(completion: @escaping (_ isActive: Bool) -> Void)
+ func refreshCachedSubscriptionAndEntitlements(completion: @escaping (_ isSubscriptionActive: 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 final class DefaultSubscriptionManager: SubscriptionManager {
+ private let _storePurchaseManager: StorePurchaseManager?
+ public let accountManager: AccountManager
+ public let subscriptionEndpointService: SubscriptionEndpointService
+ public let authEndpointService: AuthEndpointService
public let currentEnvironment: SubscriptionEnvironment
public private(set) var canPurchase: Bool = false
- public init(storePurchaseManager: StorePurchaseManaging? = nil,
- accountManager: AccountManaging,
- subscriptionService: SubscriptionService,
- authService: AuthService,
+ public init(storePurchaseManager: StorePurchaseManager? = nil,
+ accountManager: AccountManager,
+ subscriptionEndpointService: SubscriptionEndpointService,
+ authEndpointService: AuthEndpointService,
subscriptionEnvironment: SubscriptionEnvironment) {
self._storePurchaseManager = storePurchaseManager
self.accountManager = accountManager
- self.subscriptionService = subscriptionService
- self.authService = authService
+ self.subscriptionEndpointService = subscriptionEndpointService
+ self.authEndpointService = authEndpointService
self.currentEnvironment = subscriptionEnvironment
switch currentEnvironment.purchasePlatform {
case .appStore:
@@ -66,7 +69,7 @@ final public class SubscriptionManager: SubscriptionManaging {
}
@available(macOS 12.0, iOS 15.0, *)
- public func storePurchaseManager() -> StorePurchaseManaging {
+ public func storePurchaseManager() -> StorePurchaseManager {
return _storePurchaseManager!
}
@@ -101,7 +104,7 @@ final public class SubscriptionManager: SubscriptionManaging {
private func setupForStripe() {
Task {
- if case let .success(products) = await subscriptionService.getProducts() {
+ if case let .success(products) = await subscriptionEndpointService.getProducts() {
canPurchase = !products.isEmpty
}
}
@@ -112,20 +115,37 @@ final public class SubscriptionManager: SubscriptionManaging {
public func loadInitialData() {
Task {
if let token = accountManager.accessToken {
- _ = await subscriptionService.getSubscription(accessToken: token, cachePolicy: .reloadIgnoringLocalCacheData)
+ _ = await subscriptionEndpointService.getSubscription(accessToken: token, cachePolicy: .reloadIgnoringLocalCacheData)
_ = await accountManager.fetchEntitlements(cachePolicy: .reloadIgnoringLocalCacheData)
}
}
}
- public func updateSubscriptionStatus(completion: @escaping (_ isActive: Bool) -> Void) {
+ public func refreshCachedSubscriptionAndEntitlements(completion: @escaping (_ isSubscriptionActive: Bool) -> Void) {
Task {
- guard let token = accountManager.accessToken else { return }
+ guard let token = accountManager.accessToken else { return }
+
+ var isSubscriptionActive = false
+
+ defer {
+ completion(isSubscriptionActive)
+ }
- if case .success(let subscription) = await subscriptionService.getSubscription(accessToken: token, cachePolicy: .reloadIgnoringLocalCacheData) {
- completion(subscription.isActive)
+ // Refetch and cache subscription
+ switch await subscriptionEndpointService.getSubscription(accessToken: token, cachePolicy: .reloadIgnoringLocalCacheData) {
+ case .success(let subscription):
+ isSubscriptionActive = subscription.isActive
+ case .failure(let error):
+ if case let .apiError(serviceError) = error, case let .serverError(statusCode, _) = serviceError {
+ if statusCode == 401 {
+ // Token is no longer valid
+ accountManager.signOut()
+ return
+ }
+ }
}
+ // Refetch and cache entitlements
_ = await accountManager.fetchEntitlements(cachePolicy: .reloadIgnoringLocalCacheData)
}
}
diff --git a/Sources/Subscription/Services/AuthService.swift b/Sources/Subscription/Services/AuthService.swift
deleted file mode 100644
index d35ac3b0c..000000000
--- a/Sources/Subscription/Services/AuthService.swift
+++ /dev/null
@@ -1,117 +0,0 @@
-//
-// AuthService.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 struct AuthService: APIService {
-
- 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 var baseURL: URL {
- switch currentServiceEnvironment {
- case .production:
- URL(string: "https://quack.duckduckgo.com/api/auth")!
- case .staging:
- URL(string: "https://quackdev.duckduckgo.com/api/auth")!
- }
- }
-
- // MARK: -
-
- public func getAccessToken(token: String) async -> Result {
- await executeAPICall(method: "GET", endpoint: "access-token", headers: makeAuthorizationHeader(for: token))
- }
-
- public struct AccessTokenResponse: Decodable {
- public let accessToken: String
- }
-
- // MARK: -
-
- public func validateToken(accessToken: String) async -> Result {
- await executeAPICall(method: "GET", endpoint: "validate-token", headers: makeAuthorizationHeader(for: accessToken))
- }
-
- public struct ValidateTokenResponse: Decodable {
- public let account: Account
-
- public struct Account: Decodable {
- public let email: String?
- let entitlements: [Entitlement]
- public let externalID: String
-
- enum CodingKeys: String, CodingKey {
- case email, entitlements, externalID = "externalId" // no underscores due to keyDecodingStrategy = .convertFromSnakeCase
- }
- }
- }
-
- // MARK: -
-
- public func createAccount(emailAccessToken: String?) async -> Result {
- var headers: [String: String]?
-
- if let emailAccessToken {
- headers = makeAuthorizationHeader(for: emailAccessToken)
- }
-
- return await executeAPICall(method: "POST", endpoint: "account/create", headers: headers)
- }
-
- public struct CreateAccountResponse: Decodable {
- public let authToken: String
- public let externalID: String
- public let status: String
-
- enum CodingKeys: String, CodingKey {
- case authToken = "authToken", externalID = "externalId", status // no underscores due to keyDecodingStrategy = .convertFromSnakeCase
- }
- }
-
- // MARK: -
-
- public func storeLogin(signature: String) async -> Result {
- let bodyDict = ["signature": signature,
- "store": "apple_app_store"]
-
- guard let bodyData = try? JSONEncoder().encode(bodyDict) else { return .failure(.encodingError) }
- return await executeAPICall(method: "POST", endpoint: "store-login", body: bodyData)
- }
-
- public struct StoreLoginResponse: Decodable {
- public let authToken: String
- public let email: String
- public let externalID: String
- public let id: Int
- public let status: String
-
- enum CodingKeys: String, CodingKey {
- case authToken = "authToken", email, externalID = "externalId", id, status // no underscores due to keyDecodingStrategy = .convertFromSnakeCase
- }
- }
-}
diff --git a/Sources/Subscription/SubManagers/AccountManaging.swift b/Sources/Subscription/SubManagers/AccountManaging.swift
deleted file mode 100644
index 673860ef7..000000000
--- a/Sources/Subscription/SubManagers/AccountManaging.swift
+++ /dev/null
@@ -1,58 +0,0 @@
-//
-// 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/SubManagers/StorePurchaseManaging.swift b/Sources/Subscription/SubManagers/StorePurchaseManaging.swift
deleted file mode 100644
index c4ac847cb..000000000
--- a/Sources/Subscription/SubManagers/StorePurchaseManaging.swift
+++ /dev/null
@@ -1,58 +0,0 @@
-//
-// 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/SubscriptionURL.swift b/Sources/Subscription/SubscriptionURL.swift
index 6c99c151f..a6cd6a10e 100644
--- a/Sources/Subscription/SubscriptionURL.swift
+++ b/Sources/Subscription/SubscriptionURL.swift
@@ -34,7 +34,7 @@ public enum SubscriptionURL {
case identityTheftRestoration
// swiftlint:disable:next cyclomatic_complexity
- func subscriptionURL(environment: SubscriptionEnvironment.ServiceEnvironment) -> URL {
+ public func subscriptionURL(environment: SubscriptionEnvironment.ServiceEnvironment) -> URL {
switch self {
case .baseURL:
switch environment {
diff --git a/Sources/SubscriptionTestingUtilities/APIs/APIServiceMock.swift b/Sources/SubscriptionTestingUtilities/APIs/APIServiceMock.swift
new file mode 100644
index 000000000..ab7916690
--- /dev/null
+++ b/Sources/SubscriptionTestingUtilities/APIs/APIServiceMock.swift
@@ -0,0 +1,47 @@
+//
+// APIServiceMock.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 struct APIServiceMock: APIService {
+ public var mockAuthHeaders: [String: String]
+ public var mockAPICallSuccessResult: Any?
+ public var mockAPICallError: APIServiceError?
+
+ public init(mockAuthHeaders: [String: String], mockAPICallSuccessResult: Any? = nil, mockAPICallError: APIServiceError? = nil) {
+ self.mockAuthHeaders = mockAuthHeaders
+ self.mockAPICallSuccessResult = mockAPICallSuccessResult
+ self.mockAPICallError = mockAPICallError
+ }
+
+ // swiftlint:disable force_cast
+ public func executeAPICall(method: String, endpoint: String, headers: [String: String]?, body: Data?) async -> Result where T: Decodable {
+ if let success = mockAPICallSuccessResult {
+ return .success(success as! T)
+ } else if let error = mockAPICallError {
+ return .failure(error)
+ }
+ return .failure(.unknownServerError)
+ }
+ // swiftlint:enable force_cast
+
+ public func makeAuthorizationHeader(for token: String) -> [String: String] {
+ return mockAuthHeaders
+ }
+}
diff --git a/Sources/SubscriptionTestingUtilities/APIs/AuthEndpointServiceMock.swift b/Sources/SubscriptionTestingUtilities/APIs/AuthEndpointServiceMock.swift
new file mode 100644
index 000000000..3284b616b
--- /dev/null
+++ b/Sources/SubscriptionTestingUtilities/APIs/AuthEndpointServiceMock.swift
@@ -0,0 +1,53 @@
+//
+// AuthEndpointServiceMock.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 struct AuthEndpointServiceMock: AuthEndpointService {
+ public var accessTokenResult: Result?
+ public var validateTokenResult: Result?
+ public var createAccountResult: Result?
+ public var storeLoginResult: Result?
+
+ public init(accessTokenResult: Result?,
+ validateTokenResult: Result?,
+ createAccountResult: Result?,
+ storeLoginResult: Result?) {
+ self.accessTokenResult = accessTokenResult
+ self.validateTokenResult = validateTokenResult
+ self.createAccountResult = createAccountResult
+ self.storeLoginResult = storeLoginResult
+ }
+
+ public func getAccessToken(token: String) async -> Result {
+ accessTokenResult!
+ }
+
+ public func validateToken(accessToken: String) async -> Result {
+ validateTokenResult!
+ }
+
+ public func createAccount(emailAccessToken: String?) async -> Result {
+ createAccountResult!
+ }
+
+ public func storeLogin(signature: String) async -> Result {
+ storeLoginResult!
+ }
+}
diff --git a/Sources/SubscriptionTestingUtilities/APIs/SubscriptionEndpointServiceMock.swift b/Sources/SubscriptionTestingUtilities/APIs/SubscriptionEndpointServiceMock.swift
new file mode 100644
index 000000000..0890fe30d
--- /dev/null
+++ b/Sources/SubscriptionTestingUtilities/APIs/SubscriptionEndpointServiceMock.swift
@@ -0,0 +1,61 @@
+//
+// SubscriptionEndpointServiceMock.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 struct SubscriptionEndpointServiceMock: SubscriptionEndpointService {
+ public var getSubscriptionResult: Result?
+ public var getProductsResult: Result<[GetProductsItem], APIServiceError>?
+ public var getCustomerPortalURLResult: Result?
+ public var confirmPurchaseResult: Result?
+
+ public init(getSubscriptionResult: Result? = nil,
+ getProductsResult: Result<[GetProductsItem], APIServiceError>? = nil,
+ getCustomerPortalURLResult: Result? = nil,
+ confirmPurchaseResult: Result? = nil) {
+ self.getSubscriptionResult = getSubscriptionResult
+ self.getProductsResult = getProductsResult
+ self.getCustomerPortalURLResult = getCustomerPortalURLResult
+ self.confirmPurchaseResult = confirmPurchaseResult
+ }
+
+ public func updateCache(with subscription: Subscription) {
+
+ }
+
+ public func getSubscription(accessToken: String, cachePolicy: APICachePolicy) async -> Result {
+ getSubscriptionResult!
+ }
+
+ public func signOut() {
+
+ }
+
+ public func getProducts() async -> Result<[GetProductsItem], APIServiceError> {
+ getProductsResult!
+ }
+
+ public func getCustomerPortalURL(accessToken: String, externalID: String) async -> Result {
+ getCustomerPortalURLResult!
+ }
+
+ public func confirmPurchase(accessToken: String, signature: String) async -> Result {
+ confirmPurchaseResult!
+ }
+}
diff --git a/Sources/SubscriptionTestingUtilities/AccountKeychainStorageMock.swift b/Sources/SubscriptionTestingUtilities/AccountKeychainStorageMock.swift
new file mode 100644
index 000000000..bf48ee36d
--- /dev/null
+++ b/Sources/SubscriptionTestingUtilities/AccountKeychainStorageMock.swift
@@ -0,0 +1,73 @@
+//
+// AccountKeychainStorageMock.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 class AccountKeychainStorageMock: AccountStoring {
+ public var authToken: String?
+ public var accessToken: String?
+ public var email: String?
+ public var externalID: String?
+
+ public init(authToken: String? = nil, accessToken: String? = nil, email: String? = nil, externalID: String? = nil) {
+ self.authToken = authToken
+ self.accessToken = accessToken
+ self.email = email
+ self.externalID = externalID
+ }
+
+ public func getAuthToken() throws -> String? {
+ authToken
+ }
+
+ public func store(authToken: String) throws {
+ self.authToken = authToken
+ }
+
+ public func getAccessToken() throws -> String? {
+ accessToken
+ }
+
+ public func store(accessToken: String) throws {
+ self.accessToken = accessToken
+ }
+
+ public func getEmail() throws -> String? {
+ email
+ }
+
+ public func store(email: String?) throws {
+ self.email = email
+ }
+
+ public func getExternalID() throws -> String? {
+ externalID
+ }
+
+ public func store(externalID: String?) throws {
+ self.externalID = externalID
+ }
+
+ public func clearAuthenticationState() throws {
+ authToken = nil
+ accessToken = nil
+ email = nil
+ externalID = nil
+ }
+}
diff --git a/Sources/SubscriptionTestingUtilities/AccountManagerMock.swift b/Sources/SubscriptionTestingUtilities/AccountManagerMock.swift
index 070f827b5..4755d9e49 100644
--- a/Sources/SubscriptionTestingUtilities/AccountManagerMock.swift
+++ b/Sources/SubscriptionTestingUtilities/AccountManagerMock.swift
@@ -19,23 +19,19 @@
import Foundation
import Subscription
-public final class AccountManagerMock: AccountManaging {
-
+public final class AccountManagerMock: AccountManager {
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
@@ -62,11 +58,7 @@ public final class AccountManagerMock: AccountManaging {
}
- public func hasEntitlement(for entitlement: Entitlement.ProductName, cachePolicy: CachePolicy) async -> Result {
- return .success(true)
- }
-
- public func hasEntitlement(for entitlement: Entitlement.ProductName) async -> Result {
+ public func hasEntitlement(forProductName productName: Entitlement.ProductName, cachePolicy: APICachePolicy) async -> Result {
return .success(true)
}
@@ -74,7 +66,7 @@ public final class AccountManagerMock: AccountManaging {
}
- public func fetchEntitlements(cachePolicy: CachePolicy) async -> Result<[Entitlement], Error> {
+ public func fetchEntitlements(cachePolicy: APICachePolicy) async -> Result<[Entitlement], Error> {
return .success([])
}
@@ -91,10 +83,6 @@ public final class AccountManagerMock: AccountManaging {
}
}
- public func refreshSubscriptionAndEntitlements() async {
-
- }
-
public func checkForEntitlements(wait waitTime: Double, retry retryCount: Int) async -> Bool {
return true
}
diff --git a/Sources/SubscriptionTestingUtilities/Flows/AppStoreAccountManagementFlowMock.swift b/Sources/SubscriptionTestingUtilities/Flows/AppStoreAccountManagementFlowMock.swift
new file mode 100644
index 000000000..79bd4ba9c
--- /dev/null
+++ b/Sources/SubscriptionTestingUtilities/Flows/AppStoreAccountManagementFlowMock.swift
@@ -0,0 +1,32 @@
+//
+// AppStoreAccountManagementFlowMock.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 class AppStoreAccountManagementFlowMock: AppStoreAccountManagementFlow {
+ public var refreshAuthTokenIfNeededResult: Result
+
+ public init(refreshAuthTokenIfNeededResult: Result) {
+ self.refreshAuthTokenIfNeededResult = refreshAuthTokenIfNeededResult
+ }
+
+ public func refreshAuthTokenIfNeeded() async -> Result {
+ refreshAuthTokenIfNeededResult
+ }
+}
diff --git a/Sources/SubscriptionTestingUtilities/Flows/AppStorePurchaseFlowMock.swift b/Sources/SubscriptionTestingUtilities/Flows/AppStorePurchaseFlowMock.swift
new file mode 100644
index 000000000..b471de47c
--- /dev/null
+++ b/Sources/SubscriptionTestingUtilities/Flows/AppStorePurchaseFlowMock.swift
@@ -0,0 +1,38 @@
+//
+// AppStorePurchaseFlowMock.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 class AppStorePurchaseFlowMock: AppStorePurchaseFlow {
+ public var purchaseSubscriptionResult: Result
+ public var completeSubscriptionPurchaseResult: Result
+
+ public init(purchaseSubscriptionResult: Result, completeSubscriptionPurchaseResult: Result) {
+ self.purchaseSubscriptionResult = purchaseSubscriptionResult
+ self.completeSubscriptionPurchaseResult = completeSubscriptionPurchaseResult
+ }
+
+ public func purchaseSubscription(with subscriptionIdentifier: String, emailAccessToken: String?) async -> Result {
+ purchaseSubscriptionResult
+ }
+
+ public func completeSubscriptionPurchase(with transactionJWS: TransactionJWS) async -> Result {
+ completeSubscriptionPurchaseResult
+ }
+}
diff --git a/Sources/SubscriptionTestingUtilities/Flows/AppStoreRestoreFlowMock.swift b/Sources/SubscriptionTestingUtilities/Flows/AppStoreRestoreFlowMock.swift
new file mode 100644
index 000000000..55a7ba094
--- /dev/null
+++ b/Sources/SubscriptionTestingUtilities/Flows/AppStoreRestoreFlowMock.swift
@@ -0,0 +1,32 @@
+//
+// AppStoreRestoreFlowMock.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 class AppStoreRestoreFlowMock: AppStoreRestoreFlow {
+ public var restoreAccountFromPastPurchaseResult: Result
+
+ public init(restoreAccountFromPastPurchaseResult: Result) {
+ self.restoreAccountFromPastPurchaseResult = restoreAccountFromPastPurchaseResult
+ }
+
+ public func restoreAccountFromPastPurchase() async -> Result {
+ restoreAccountFromPastPurchaseResult
+ }
+}
diff --git a/Sources/SubscriptionTestingUtilities/Flows/StripePurchaseFlowMock.swift b/Sources/SubscriptionTestingUtilities/Flows/StripePurchaseFlowMock.swift
new file mode 100644
index 000000000..8f7b0b5f1
--- /dev/null
+++ b/Sources/SubscriptionTestingUtilities/Flows/StripePurchaseFlowMock.swift
@@ -0,0 +1,42 @@
+//
+// StripePurchaseFlowMock.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 class StripePurchaseFlowMock: StripePurchaseFlow {
+ public var subscriptionOptionsResult: Result
+ public var prepareSubscriptionPurchaseResult: Result
+
+ public init(subscriptionOptionsResult: Result, prepareSubscriptionPurchaseResult: Result) {
+ self.subscriptionOptionsResult = subscriptionOptionsResult
+ self.prepareSubscriptionPurchaseResult = prepareSubscriptionPurchaseResult
+ }
+
+ public func subscriptionOptions() async -> Result {
+ subscriptionOptionsResult
+ }
+
+ public func prepareSubscriptionPurchase(emailAccessToken: String?) async -> Result {
+ prepareSubscriptionPurchaseResult
+ }
+
+ public func completeSubscriptionPurchase() async {
+
+ }
+}
diff --git a/Sources/SubscriptionTestingUtilities/README.md b/Sources/SubscriptionTestingUtilities/README.md
new file mode 100644
index 000000000..e73f025a3
--- /dev/null
+++ b/Sources/SubscriptionTestingUtilities/README.md
@@ -0,0 +1,25 @@
+# Subscription manual smoke tests
+
+### Common:
+- Search for "privacy pro" in DDG and open the /pro link: it should intercept and open the purchase flow
+- (on Mac) with active subscription, open welcome page, DBP, ITR and FAQ tabs, sign out: all tabs are closed automatically
+
+### App store:
+- Buy
+- Remove from device
+- Cancel in the middle of buying
+- Simulate an error while buying (in code)
+- Restore successfully
+- Restore inexistent subscription
+- After purchase, sign out and try to buy again
+- Add another email
+- Manage email > Remove
+- When purchased, remove from device and attempt purchase again: shows alert suggesting to restore
+- Restore when the subscription is expired: triggers the "view plans" prompt
+
+### Stripe:
+- Buy
+- Remove
+- Restore successfully
+- Add another email
+- Manage email > Remove
diff --git a/Sources/SubscriptionTestingUtilities/StorePurchaseManagerMock.swift b/Sources/SubscriptionTestingUtilities/StorePurchaseManagerMock.swift
new file mode 100644
index 000000000..9829fb2df
--- /dev/null
+++ b/Sources/SubscriptionTestingUtilities/StorePurchaseManagerMock.swift
@@ -0,0 +1,75 @@
+//
+// StorePurchaseManagerMock.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 struct StorePurchaseManagerMock: StorePurchaseManager {
+ public var purchasedProductIDs: [String]
+ public var purchaseQueue: [String]
+ public var areProductsAvailable: Bool
+ public var subscriptionOptionsResult: SubscriptionOptions?
+ public var syncAppleIDAccountResultError: Error?
+ public var mostRecentTransactionResult: String?
+ public var hasActiveSubscriptionResult: Bool
+ public var purchaseSubscriptionResult: Result
+
+ public init(purchasedProductIDs: [String],
+ purchaseQueue: [String],
+ areProductsAvailable: Bool,
+ subscriptionOptionsResult: SubscriptionOptions? = nil,
+ syncAppleIDAccountResultError: Error? = nil,
+ mostRecentTransactionResult: String? = nil,
+ hasActiveSubscriptionResult: Bool,
+ purchaseSubscriptionResult: Result) {
+ self.purchasedProductIDs = purchasedProductIDs
+ self.purchaseQueue = purchaseQueue
+ self.areProductsAvailable = areProductsAvailable
+ self.subscriptionOptionsResult = subscriptionOptionsResult
+ self.syncAppleIDAccountResultError = syncAppleIDAccountResultError
+ self.mostRecentTransactionResult = mostRecentTransactionResult
+ self.hasActiveSubscriptionResult = hasActiveSubscriptionResult
+ self.purchaseSubscriptionResult = purchaseSubscriptionResult
+ }
+
+ public func subscriptionOptions() async -> SubscriptionOptions? {
+ subscriptionOptionsResult
+ }
+
+ public func syncAppleIDAccount() async throws {
+ if let syncAppleIDAccountResultError {
+ throw syncAppleIDAccountResultError
+ }
+ }
+
+ public func updateAvailableProducts() async { }
+
+ public func updatePurchasedProducts() async { }
+
+ public func mostRecentTransaction() async -> String? {
+ mostRecentTransactionResult
+ }
+
+ public func hasActiveSubscription() async -> Bool {
+ hasActiveSubscriptionResult
+ }
+
+ public func purchaseSubscription(with identifier: String, externalID: String) async -> Result {
+ purchaseSubscriptionResult
+ }
+}
diff --git a/Sources/SubscriptionTestingUtilities/SubscriptionManagerMock.swift b/Sources/SubscriptionTestingUtilities/SubscriptionManagerMock.swift
index ca2566eaf..46fdc77e0 100644
--- a/Sources/SubscriptionTestingUtilities/SubscriptionManagerMock.swift
+++ b/Sources/SubscriptionTestingUtilities/SubscriptionManagerMock.swift
@@ -19,15 +19,24 @@
import Foundation
@testable import Subscription
-public final class SubscriptionManagerMock: SubscriptionManaging {
+public final class SubscriptionManagerMock: SubscriptionManager {
+ public var accountManager: AccountManager
+ public var subscriptionEndpointService: SubscriptionEndpointService
+ public var authEndpointService: AuthEndpointService
+
+ public static var storedEnvironment: SubscriptionEnvironment?
+ public static func loadEnvironmentFrom(userDefaults: UserDefaults) -> SubscriptionEnvironment? {
+ return storedEnvironment
+ }
+
+ public static func save(subscriptionEnvironment: SubscriptionEnvironment, userDefaults: UserDefaults) {
+ storedEnvironment = subscriptionEnvironment
+ }
- public var accountManager: AccountManaging
- public var subscriptionService: SubscriptionService
- public var authService: AuthService
public var currentEnvironment: SubscriptionEnvironment
public var canPurchase: Bool
- public func storePurchaseManager() -> StorePurchaseManaging {
+ public func storePurchaseManager() -> StorePurchaseManager {
internalStorePurchaseManager
}
@@ -35,7 +44,7 @@ public final class SubscriptionManagerMock: SubscriptionManaging {
}
- public func updateSubscriptionStatus(completion: @escaping (Bool) -> Void) {
+ public func refreshCachedSubscriptionAndEntitlements(completion: @escaping (Bool) -> Void) {
completion(true)
}
@@ -43,15 +52,15 @@ public final class SubscriptionManagerMock: SubscriptionManaging {
type.subscriptionURL(environment: currentEnvironment.serviceEnvironment)
}
- public init(accountManager: AccountManaging,
- subscriptionService: SubscriptionService,
- authService: AuthService,
- storePurchaseManager: StorePurchaseManaging,
+ public init(accountManager: AccountManager,
+ subscriptionEndpointService: SubscriptionEndpointService,
+ authEndpointService: AuthEndpointService,
+ storePurchaseManager: StorePurchaseManager,
currentEnvironment: SubscriptionEnvironment,
canPurchase: Bool) {
self.accountManager = accountManager
- self.subscriptionService = subscriptionService
- self.authService = authService
+ self.subscriptionEndpointService = subscriptionEndpointService
+ self.authEndpointService = authEndpointService
self.internalStorePurchaseManager = storePurchaseManager
self.currentEnvironment = currentEnvironment
self.canPurchase = canPurchase
@@ -59,5 +68,5 @@ public final class SubscriptionManagerMock: SubscriptionManaging {
// MARK: -
- let internalStorePurchaseManager: StorePurchaseManaging
+ let internalStorePurchaseManager: StorePurchaseManager
}
diff --git a/Sources/SubscriptionTestingUtilities/SubscriptionMockFactory.swift b/Sources/SubscriptionTestingUtilities/SubscriptionMockFactory.swift
new file mode 100644
index 000000000..33fa98ec2
--- /dev/null
+++ b/Sources/SubscriptionTestingUtilities/SubscriptionMockFactory.swift
@@ -0,0 +1,90 @@
+//
+// SubscriptionMockFactory.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
+
+/// Provides all mocks needed for testing subscription initialised with positive outcomes and basic configurations. All mocks can be partially reconfigured with failures or incorrect data
+public struct SubscriptionMockFactory {
+
+ public static let email = "5p2d4sx1@duck.com" // Some sandbox account
+ public static let externalId = UUID().uuidString
+ public static let accountManager = AccountManagerMock(email: email,
+ externalID: externalId)
+ /// No mock result or error configured, that must be done per-test basis
+ public static let apiService = APIServiceMock(mockAuthHeaders: [:])
+ public static let subscription = Subscription(productId: UUID().uuidString,
+ name: "Subscription test #1",
+ billingPeriod: .monthly,
+ startedAt: Date(),
+ expiresOrRenewsAt: Date().addingTimeInterval(TimeInterval.days(+30)),
+ platform: .apple,
+ status: .autoRenewable)
+ public static let productsItems: [GetProductsItem] = [GetProductsItem(productId: subscription.productId,
+ productLabel: subscription.name,
+ billingPeriod: subscription.billingPeriod.rawValue,
+ price: "0.99",
+ currency: "USD")]
+ public static let customerPortalURL = GetCustomerPortalURLResponse(customerPortalUrl: "https://duckduckgo.com")
+ public static let entitlements = [Entitlement(product: .dataBrokerProtection),
+ Entitlement(product: .identityTheftRestoration),
+ Entitlement(product: .networkProtection)]
+ public static let confirmPurchase = ConfirmPurchaseResponse(email: email,
+ entitlements: entitlements,
+ subscription: subscription)
+ public static let subscriptionEndpointService = SubscriptionEndpointServiceMock(getSubscriptionResult: .success(subscription),
+ getProductsResult: .success(productsItems),
+ getCustomerPortalURLResult: .success(customerPortalURL),
+ confirmPurchaseResult: .success(confirmPurchase))
+ public static let authToken = "someAuthToken"
+
+ private static let validateTokenResponse = ValidateTokenResponse(account: ValidateTokenResponse.Account(email: email,
+ entitlements: entitlements,
+ externalID: UUID().uuidString))
+ public static let authEndpointService = AuthEndpointServiceMock(accessTokenResult: .success(AccessTokenResponse(accessToken: "SomeAccessToken")),
+ validateTokenResult: .success(validateTokenResponse),
+ createAccountResult: .success(CreateAccountResponse(authToken: authToken,
+ externalID: "?",
+ status: "?")),
+ storeLoginResult: .success(StoreLoginResponse(authToken: authToken,
+ email: email,
+ externalID: UUID().uuidString,
+ id: 1,
+ status: "?")))
+
+ public static let storePurchaseManager = StorePurchaseManagerMock(purchasedProductIDs: [UUID().uuidString],
+ purchaseQueue: ["?"],
+ areProductsAvailable: true,
+ subscriptionOptionsResult: SubscriptionOptions.empty,
+ syncAppleIDAccountResultError: nil,
+ mostRecentTransactionResult: nil,
+ hasActiveSubscriptionResult: false,
+ purchaseSubscriptionResult: .success("someTransactionJWS"))
+
+ public static let currentEnvironment = SubscriptionEnvironment(serviceEnvironment: .staging,
+ purchasePlatform: .appStore)
+
+ public static let subscriptionManager = SubscriptionManagerMock(accountManager: accountManager,
+ subscriptionEndpointService: subscriptionEndpointService,
+ authEndpointService: authEndpointService,
+ storePurchaseManager: storePurchaseManager,
+ currentEnvironment: currentEnvironment,
+ canPurchase: true)
+
+ public static let appStoreRestoreFlow = AppStoreRestoreFlowMock(restoreAccountFromPastPurchaseResult: .success(Void()))
+}
diff --git a/Sources/SubscriptionTestingUtilities/SubscriptionTokenKeychainStorageMock.swift b/Sources/SubscriptionTestingUtilities/SubscriptionTokenKeychainStorageMock.swift
new file mode 100644
index 000000000..8e4e61912
--- /dev/null
+++ b/Sources/SubscriptionTestingUtilities/SubscriptionTokenKeychainStorageMock.swift
@@ -0,0 +1,40 @@
+//
+// SubscriptionTokenKeychainStorageMock.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 class SubscriptionTokenKeychainStorageMock: SubscriptionTokenStoring {
+ public var accessToken: String?
+
+ public init(accessToken: String? = nil) {
+ self.accessToken = accessToken
+ }
+
+ public func getAccessToken() throws -> String? {
+ accessToken
+ }
+
+ public func store(accessToken: String) throws {
+ self.accessToken = accessToken
+ }
+
+ public func removeAccessToken() throws {
+ accessToken = nil
+ }
+}
diff --git a/Sources/WireGuardC/include/WireGuardC.h b/Sources/WireGuardC/include/WireGuardC.h
index 36218b9c5..34a39d139 100644
--- a/Sources/WireGuardC/include/WireGuardC.h
+++ b/Sources/WireGuardC/include/WireGuardC.h
@@ -3,6 +3,7 @@
#include "key.h"
#include "x25519.h"
+#include
/* From */
#define CTLIOCGINFO 0xc0644e03UL
diff --git a/Tests/BrowserServicesKitTests/Autofill/AutofillPixelReporterTests.swift b/Tests/BrowserServicesKitTests/Autofill/AutofillPixelReporterTests.swift
index ae1494e3d..ad675dada 100644
--- a/Tests/BrowserServicesKitTests/Autofill/AutofillPixelReporterTests.swift
+++ b/Tests/BrowserServicesKitTests/Autofill/AutofillPixelReporterTests.swift
@@ -29,6 +29,7 @@ final class AutofillPixelReporterTests: XCTestCase {
static var events: [AutofillPixelEvent] = []
static var loginsParam: String?
static var creditCardsParam: String?
+ static var identitiesParam: String?
public init() {
super.init { event, _, param, _ in
@@ -38,6 +39,8 @@ final class AutofillPixelReporterTests: XCTestCase {
Self.loginsParam = param?[AutofillPixelEvent.Parameter.countBucket]
case .autofillCreditCardsStacked:
Self.creditCardsParam = param?[AutofillPixelEvent.Parameter.countBucket]
+ case .autofillIdentitiesStacked:
+ Self.identitiesParam = param?[AutofillPixelEvent.Parameter.countBucket]
default:
break
}
@@ -90,19 +93,21 @@ final class AutofillPixelReporterTests: XCTestCase {
XCTAssertEqual(MockEventMapping.events.count, 0)
}
- func testWhenFirstFillAndSearchDauIsTodayAndAccountsCountIsZeroThenThreeEventsAreFiredWithNoneParams() {
+ func testWhenFirstFillAndSearchDauIsTodayAndAccountsCountIsZeroThenFourEventsAreFiredWithNoneParams() {
let autofillPixelReporter = createAutofillPixelReporter()
autofillPixelReporter.resetStoreDefaults()
setAutofillSearchDauDate(daysAgo: 0)
NotificationCenter.default.post(name: .autofillFillEvent, object: nil)
- XCTAssertEqual(MockEventMapping.events.count, 3)
+ XCTAssertEqual(MockEventMapping.events.count, 4)
XCTAssertTrue(MockEventMapping.events.contains(.autofillActiveUser))
XCTAssertTrue(MockEventMapping.events.contains(.autofillLoginsStacked))
XCTAssertTrue(MockEventMapping.events.contains(.autofillCreditCardsStacked))
+ XCTAssertTrue(MockEventMapping.events.contains(.autofillIdentitiesStacked))
XCTAssertEqual(MockEventMapping.loginsParam, AutofillPixelReporter.BucketName.none.rawValue)
XCTAssertEqual(MockEventMapping.creditCardsParam, AutofillPixelReporter.BucketName.none.rawValue)
+ XCTAssertEqual(MockEventMapping.identitiesParam, AutofillPixelReporter.BucketName.none.rawValue)
}
func testWhenFirstSearchDauAndAutofillDisabledAndFillDateIsNotTodayAndAccountsCountIsZeroThenOneEventIsFired() throws {
@@ -155,7 +160,7 @@ final class AutofillPixelReporterTests: XCTestCase {
XCTAssertTrue(MockEventMapping.events.contains(.autofillToggledOn))
}
- func testWhenFirstSearchDauAndThenFirstFillAndAccountsCountIsZeroThenFourEventsAreFiredWithNoneParams() {
+ func testWhenFirstSearchDauAndThenFirstFillAndAccountsCountIsZeroThenFiveEventsAreFiredWithNoneParams() {
let autofillPixelReporter = createAutofillPixelReporter()
autofillPixelReporter.resetStoreDefaults()
createAccountsInVault(count: 0)
@@ -163,16 +168,18 @@ final class AutofillPixelReporterTests: XCTestCase {
NotificationCenter.default.post(name: .searchDAU, object: nil)
NotificationCenter.default.post(name: .autofillFillEvent, object: nil)
- XCTAssertEqual(MockEventMapping.events.count, 4)
+ XCTAssertEqual(MockEventMapping.events.count, 5)
XCTAssertTrue(MockEventMapping.events.contains(.autofillActiveUser))
XCTAssertTrue(MockEventMapping.events.contains(.autofillToggledOn))
XCTAssertTrue(MockEventMapping.events.contains(.autofillLoginsStacked))
XCTAssertTrue(MockEventMapping.events.contains(.autofillCreditCardsStacked))
+ XCTAssertTrue(MockEventMapping.events.contains(.autofillIdentitiesStacked))
XCTAssertEqual(MockEventMapping.loginsParam, AutofillPixelReporter.BucketName.none.rawValue)
XCTAssertEqual(MockEventMapping.creditCardsParam, AutofillPixelReporter.BucketName.none.rawValue)
+ XCTAssertEqual(MockEventMapping.identitiesParam, AutofillPixelReporter.BucketName.none.rawValue)
}
- func testWhenFirstSearchDauAndThenFirstFillAndAccountsCountIsThreeThenFourEventsAreFiredWithCorrectParams() {
+ func testWhenFirstSearchDauAndThenFirstFillAndAccountsCountIsThreeThenFiveEventsAreFiredWithCorrectParams() {
let autofillPixelReporter = createAutofillPixelReporter()
autofillPixelReporter.resetStoreDefaults()
createAccountsInVault(count: 3)
@@ -180,16 +187,18 @@ final class AutofillPixelReporterTests: XCTestCase {
NotificationCenter.default.post(name: .searchDAU, object: nil)
NotificationCenter.default.post(name: .autofillFillEvent, object: nil)
- XCTAssertEqual(MockEventMapping.events.count, 4)
+ XCTAssertEqual(MockEventMapping.events.count, 5)
XCTAssertTrue(MockEventMapping.events.contains(.autofillActiveUser))
XCTAssertTrue(MockEventMapping.events.contains(.autofillToggledOn))
XCTAssertTrue(MockEventMapping.events.contains(.autofillLoginsStacked))
XCTAssertTrue(MockEventMapping.events.contains(.autofillCreditCardsStacked))
+ XCTAssertTrue(MockEventMapping.events.contains(.autofillIdentitiesStacked))
XCTAssertEqual(MockEventMapping.loginsParam, AutofillPixelReporter.BucketName.few.rawValue)
XCTAssertEqual(MockEventMapping.creditCardsParam, AutofillPixelReporter.BucketName.none.rawValue)
+ XCTAssertEqual(MockEventMapping.identitiesParam, AutofillPixelReporter.BucketName.none.rawValue)
}
- func testWhenFirstSearchDauAndThenFirstFillAndAccountsCountIsTenThenFiveEventsAreFiredWithCorrectParams() {
+ func testWhenFirstSearchDauAndThenFirstFillAndAccountsCountIsTenThenSixEventsAreFiredWithCorrectParams() {
let autofillPixelReporter = createAutofillPixelReporter()
autofillPixelReporter.resetStoreDefaults()
createAccountsInVault(count: 10)
@@ -197,17 +206,19 @@ final class AutofillPixelReporterTests: XCTestCase {
NotificationCenter.default.post(name: .searchDAU, object: nil)
NotificationCenter.default.post(name: .autofillFillEvent, object: nil)
- XCTAssertEqual(MockEventMapping.events.count, 5)
+ XCTAssertEqual(MockEventMapping.events.count, 6)
XCTAssertTrue(MockEventMapping.events.contains(.autofillActiveUser))
XCTAssertTrue(MockEventMapping.events.contains(.autofillToggledOn))
XCTAssertTrue(MockEventMapping.events.contains(.autofillLoginsStacked))
XCTAssertTrue(MockEventMapping.events.contains(.autofillCreditCardsStacked))
+ XCTAssertTrue(MockEventMapping.events.contains(.autofillIdentitiesStacked))
XCTAssertTrue(MockEventMapping.events.contains(.autofillEnabledUser))
XCTAssertEqual(MockEventMapping.loginsParam, AutofillPixelReporter.BucketName.some.rawValue)
XCTAssertEqual(MockEventMapping.creditCardsParam, AutofillPixelReporter.BucketName.none.rawValue)
+ XCTAssertEqual(MockEventMapping.identitiesParam, AutofillPixelReporter.BucketName.none.rawValue)
}
- func testWhenFirstSearchDauAndThenFirstFillAndAccountsCountIsElevenThenFiveEventsAreFiredWithManyParam() {
+ func testWhenFirstSearchDauAndThenFirstFillAndAccountsCountIsElevenThenSixEventsAreFiredWithManyParam() {
let autofillPixelReporter = createAutofillPixelReporter()
autofillPixelReporter.resetStoreDefaults()
createAccountsInVault(count: 11)
@@ -215,17 +226,19 @@ final class AutofillPixelReporterTests: XCTestCase {
NotificationCenter.default.post(name: .searchDAU, object: nil)
NotificationCenter.default.post(name: .autofillFillEvent, object: nil)
- XCTAssertEqual(MockEventMapping.events.count, 5)
+ XCTAssertEqual(MockEventMapping.events.count, 6)
XCTAssertTrue(MockEventMapping.events.contains(.autofillActiveUser))
XCTAssertTrue(MockEventMapping.events.contains(.autofillToggledOn))
XCTAssertTrue(MockEventMapping.events.contains(.autofillLoginsStacked))
XCTAssertTrue(MockEventMapping.events.contains(.autofillCreditCardsStacked))
+ XCTAssertTrue(MockEventMapping.events.contains(.autofillIdentitiesStacked))
XCTAssertTrue(MockEventMapping.events.contains(.autofillEnabledUser))
XCTAssertEqual(MockEventMapping.loginsParam, AutofillPixelReporter.BucketName.many.rawValue)
XCTAssertEqual(MockEventMapping.creditCardsParam, AutofillPixelReporter.BucketName.none.rawValue)
+ XCTAssertEqual(MockEventMapping.identitiesParam, AutofillPixelReporter.BucketName.none.rawValue)
}
- func testWhenFirstSearchDauAndThenFirstFillAndAccountsCountIsFortyThenFiveEventsAreFiredWithCorrectParams() {
+ func testWhenFirstSearchDauAndThenFirstFillAndAccountsCountIsFortyThenSixEventsAreFiredWithCorrectParams() {
let autofillPixelReporter = createAutofillPixelReporter()
autofillPixelReporter.resetStoreDefaults()
createAccountsInVault(count: 40)
@@ -233,16 +246,19 @@ final class AutofillPixelReporterTests: XCTestCase {
NotificationCenter.default.post(name: .searchDAU, object: nil)
NotificationCenter.default.post(name: .autofillFillEvent, object: nil)
- XCTAssertEqual(MockEventMapping.events.count, 5)
+ XCTAssertEqual(MockEventMapping.events.count, 6)
XCTAssertTrue(MockEventMapping.events.contains(.autofillActiveUser))
XCTAssertTrue(MockEventMapping.events.contains(.autofillToggledOn))
XCTAssertTrue(MockEventMapping.events.contains(.autofillLoginsStacked))
+ XCTAssertTrue(MockEventMapping.events.contains(.autofillCreditCardsStacked))
+ XCTAssertTrue(MockEventMapping.events.contains(.autofillIdentitiesStacked))
XCTAssertTrue(MockEventMapping.events.contains(.autofillEnabledUser))
XCTAssertEqual(MockEventMapping.loginsParam, AutofillPixelReporter.BucketName.many.rawValue)
XCTAssertEqual(MockEventMapping.creditCardsParam, AutofillPixelReporter.BucketName.none.rawValue)
+ XCTAssertEqual(MockEventMapping.identitiesParam, AutofillPixelReporter.BucketName.none.rawValue)
}
- func testWhenFirstSearchDauAndThenFirstFillAndAccountsCountIsFiftyThenFiveEventsAreFiredWithCorrectParams() {
+ func testWhenFirstSearchDauAndThenFirstFillAndAccountsCountIsFiftyThenSixEventsAreFiredWithCorrectParams() {
let autofillPixelReporter = createAutofillPixelReporter()
autofillPixelReporter.resetStoreDefaults()
createAccountsInVault(count: 50)
@@ -250,17 +266,19 @@ final class AutofillPixelReporterTests: XCTestCase {
NotificationCenter.default.post(name: .searchDAU, object: nil)
NotificationCenter.default.post(name: .autofillFillEvent, object: nil)
- XCTAssertEqual(MockEventMapping.events.count, 5)
+ XCTAssertEqual(MockEventMapping.events.count, 6)
XCTAssertTrue(MockEventMapping.events.contains(.autofillActiveUser))
XCTAssertTrue(MockEventMapping.events.contains(.autofillToggledOn))
XCTAssertTrue(MockEventMapping.events.contains(.autofillLoginsStacked))
XCTAssertTrue(MockEventMapping.events.contains(.autofillCreditCardsStacked))
+ XCTAssertTrue(MockEventMapping.events.contains(.autofillIdentitiesStacked))
XCTAssertTrue(MockEventMapping.events.contains(.autofillEnabledUser))
XCTAssertEqual(MockEventMapping.loginsParam, AutofillPixelReporter.BucketName.lots.rawValue)
XCTAssertEqual(MockEventMapping.creditCardsParam, AutofillPixelReporter.BucketName.none.rawValue)
+ XCTAssertEqual(MockEventMapping.identitiesParam, AutofillPixelReporter.BucketName.none.rawValue)
}
- func testWhenFirstSearchDauAndThenFirstFillAndCreditCardsCountIsOneThenFourEventsAreFiredWithCorrectParams() {
+ func testWhenFirstSearchDauAndThenFirstFillAndCreditCardsCountIsOneThenFiveEventsAreFiredWithCorrectParams() {
let autofillPixelReporter = createAutofillPixelReporter()
autofillPixelReporter.resetStoreDefaults()
createAccountsInVault(count: 0)
@@ -269,16 +287,18 @@ final class AutofillPixelReporterTests: XCTestCase {
NotificationCenter.default.post(name: .searchDAU, object: nil)
NotificationCenter.default.post(name: .autofillFillEvent, object: nil)
- XCTAssertEqual(MockEventMapping.events.count, 4)
+ XCTAssertEqual(MockEventMapping.events.count, 5)
XCTAssertTrue(MockEventMapping.events.contains(.autofillActiveUser))
XCTAssertTrue(MockEventMapping.events.contains(.autofillToggledOn))
XCTAssertTrue(MockEventMapping.events.contains(.autofillLoginsStacked))
XCTAssertTrue(MockEventMapping.events.contains(.autofillCreditCardsStacked))
+ XCTAssertTrue(MockEventMapping.events.contains(.autofillIdentitiesStacked))
XCTAssertEqual(MockEventMapping.loginsParam, AutofillPixelReporter.BucketName.none.rawValue)
XCTAssertEqual(MockEventMapping.creditCardsParam, AutofillPixelReporter.BucketName.some.rawValue)
+ XCTAssertEqual(MockEventMapping.identitiesParam, AutofillPixelReporter.BucketName.none.rawValue)
}
- func testWhenFirstSearchDauAndThenFirstFillAndCreditCardsCountIsThreeThenFourEventsAreFiredWithCorrectParams() {
+ func testWhenFirstSearchDauAndThenFirstFillAndCreditCardsCountIsThreeThenFiveEventsAreFiredWithCorrectParams() {
let autofillPixelReporter = createAutofillPixelReporter()
autofillPixelReporter.resetStoreDefaults()
createAccountsInVault(count: 0)
@@ -287,16 +307,18 @@ final class AutofillPixelReporterTests: XCTestCase {
NotificationCenter.default.post(name: .searchDAU, object: nil)
NotificationCenter.default.post(name: .autofillFillEvent, object: nil)
- XCTAssertEqual(MockEventMapping.events.count, 4)
+ XCTAssertEqual(MockEventMapping.events.count, 5)
XCTAssertTrue(MockEventMapping.events.contains(.autofillActiveUser))
XCTAssertTrue(MockEventMapping.events.contains(.autofillToggledOn))
XCTAssertTrue(MockEventMapping.events.contains(.autofillLoginsStacked))
XCTAssertTrue(MockEventMapping.events.contains(.autofillCreditCardsStacked))
+ XCTAssertTrue(MockEventMapping.events.contains(.autofillIdentitiesStacked))
XCTAssertEqual(MockEventMapping.loginsParam, AutofillPixelReporter.BucketName.none.rawValue)
XCTAssertEqual(MockEventMapping.creditCardsParam, AutofillPixelReporter.BucketName.some.rawValue)
+ XCTAssertEqual(MockEventMapping.identitiesParam, AutofillPixelReporter.BucketName.none.rawValue)
}
- func testWhenFirstSearchDauAndThenFirstFillAndCreditCardsCountIsFourThenFourEventsAreFiredWithCorrectParams() {
+ func testWhenFirstSearchDauAndThenFirstFillAndCreditCardsCountIsFourThenFiveEventsAreFiredWithCorrectParams() {
let autofillPixelReporter = createAutofillPixelReporter()
autofillPixelReporter.resetStoreDefaults()
createAccountsInVault(count: 0)
@@ -305,15 +327,95 @@ final class AutofillPixelReporterTests: XCTestCase {
NotificationCenter.default.post(name: .searchDAU, object: nil)
NotificationCenter.default.post(name: .autofillFillEvent, object: nil)
- XCTAssertEqual(MockEventMapping.events.count, 4)
+ XCTAssertEqual(MockEventMapping.events.count, 5)
XCTAssertTrue(MockEventMapping.events.contains(.autofillActiveUser))
XCTAssertTrue(MockEventMapping.events.contains(.autofillToggledOn))
XCTAssertTrue(MockEventMapping.events.contains(.autofillLoginsStacked))
XCTAssertTrue(MockEventMapping.events.contains(.autofillCreditCardsStacked))
+ XCTAssertTrue(MockEventMapping.events.contains(.autofillIdentitiesStacked))
XCTAssertEqual(MockEventMapping.loginsParam, AutofillPixelReporter.BucketName.none.rawValue)
XCTAssertEqual(MockEventMapping.creditCardsParam, AutofillPixelReporter.BucketName.many.rawValue)
+ XCTAssertEqual(MockEventMapping.identitiesParam, AutofillPixelReporter.BucketName.none.rawValue)
+ }
+
+ func testWhenFirstSearchDauAndThenFirstFillAndIdentitiesCountIsOneThenFiveEventsAreFiredWithCorrectParams() {
+ let autofillPixelReporter = createAutofillPixelReporter()
+ autofillPixelReporter.resetStoreDefaults()
+ createAccountsInVault(count: 0)
+ createCreditCardsInVault(count: 0)
+ createIdentitiesInVault(count: 1)
+
+ NotificationCenter.default.post(name: .searchDAU, object: nil)
+ NotificationCenter.default.post(name: .autofillFillEvent, object: nil)
+
+ XCTAssertEqual(MockEventMapping.events.count, 5)
+ XCTAssertTrue(MockEventMapping.events.contains(.autofillActiveUser))
+ XCTAssertTrue(MockEventMapping.events.contains(.autofillLoginsStacked))
+ XCTAssertTrue(MockEventMapping.events.contains(.autofillCreditCardsStacked))
+ XCTAssertTrue(MockEventMapping.events.contains(.autofillIdentitiesStacked))
+ XCTAssertEqual(MockEventMapping.loginsParam, AutofillPixelReporter.BucketName.none.rawValue)
+ XCTAssertEqual(MockEventMapping.creditCardsParam, AutofillPixelReporter.BucketName.none.rawValue)
+ XCTAssertEqual(MockEventMapping.identitiesParam, AutofillPixelReporter.BucketName.some.rawValue)
}
+ func testWhenFirstSearchDauAndThenFirstFillAndIdentitiesCountIsFourThenFiveEventsAreFiredWithCorrectParams() {
+ let autofillPixelReporter = createAutofillPixelReporter()
+ autofillPixelReporter.resetStoreDefaults()
+ createAccountsInVault(count: 0)
+ createCreditCardsInVault(count: 0)
+ createIdentitiesInVault(count: 4)
+
+ NotificationCenter.default.post(name: .searchDAU, object: nil)
+ NotificationCenter.default.post(name: .autofillFillEvent, object: nil)
+
+ XCTAssertEqual(MockEventMapping.events.count, 5)
+ XCTAssertTrue(MockEventMapping.events.contains(.autofillActiveUser))
+ XCTAssertTrue(MockEventMapping.events.contains(.autofillLoginsStacked))
+ XCTAssertTrue(MockEventMapping.events.contains(.autofillCreditCardsStacked))
+ XCTAssertTrue(MockEventMapping.events.contains(.autofillIdentitiesStacked))
+ XCTAssertEqual(MockEventMapping.loginsParam, AutofillPixelReporter.BucketName.none.rawValue)
+ XCTAssertEqual(MockEventMapping.creditCardsParam, AutofillPixelReporter.BucketName.none.rawValue)
+ XCTAssertEqual(MockEventMapping.identitiesParam, AutofillPixelReporter.BucketName.some.rawValue)
+ }
+
+ func testWhenFirstSearchDauAndThenFirstFillAndIdentitiesCountIsFiveThenFiveEventsAreFiredWithCorrectParams() {
+ let autofillPixelReporter = createAutofillPixelReporter()
+ autofillPixelReporter.resetStoreDefaults()
+ createAccountsInVault(count: 0)
+ createIdentitiesInVault(count: 5)
+
+ NotificationCenter.default.post(name: .searchDAU, object: nil)
+ NotificationCenter.default.post(name: .autofillFillEvent, object: nil)
+
+ XCTAssertEqual(MockEventMapping.events.count, 5)
+ XCTAssertTrue(MockEventMapping.events.contains(.autofillActiveUser))
+ XCTAssertTrue(MockEventMapping.events.contains(.autofillLoginsStacked))
+ XCTAssertTrue(MockEventMapping.events.contains(.autofillCreditCardsStacked))
+ XCTAssertTrue(MockEventMapping.events.contains(.autofillIdentitiesStacked))
+ XCTAssertEqual(MockEventMapping.loginsParam, AutofillPixelReporter.BucketName.none.rawValue)
+ XCTAssertEqual(MockEventMapping.creditCardsParam, AutofillPixelReporter.BucketName.none.rawValue)
+ XCTAssertEqual(MockEventMapping.identitiesParam, AutofillPixelReporter.BucketName.many.rawValue)
+ }
+
+ func testWhenFirstSearchDauAndThenFirstFillAndIdentitiesCountIsTwelveThenFiveEventsAreFiredWithCorrectParams() {
+ let autofillPixelReporter = createAutofillPixelReporter()
+ autofillPixelReporter.resetStoreDefaults()
+ createAccountsInVault(count: 0)
+ createIdentitiesInVault(count: 12)
+
+ NotificationCenter.default.post(name: .searchDAU, object: nil)
+ NotificationCenter.default.post(name: .autofillFillEvent, object: nil)
+
+ XCTAssertEqual(MockEventMapping.events.count, 5)
+ XCTAssertTrue(MockEventMapping.events.contains(.autofillActiveUser))
+ XCTAssertTrue(MockEventMapping.events.contains(.autofillLoginsStacked))
+ XCTAssertTrue(MockEventMapping.events.contains(.autofillCreditCardsStacked))
+ XCTAssertTrue(MockEventMapping.events.contains(.autofillIdentitiesStacked))
+ XCTAssertEqual(MockEventMapping.loginsParam, AutofillPixelReporter.BucketName.none.rawValue)
+ XCTAssertEqual(MockEventMapping.creditCardsParam, AutofillPixelReporter.BucketName.none.rawValue)
+ XCTAssertEqual(MockEventMapping.identitiesParam, AutofillPixelReporter.BucketName.lots.rawValue)
+ }
+
func testWhenSubsequentFillAndSearchDauIsNotTodayThenNoEventsAreFired() {
let autofillPixelReporter = createAutofillPixelReporter()
autofillPixelReporter.resetStoreDefaults()
@@ -442,6 +544,23 @@ final class AutofillPixelReporterTests: XCTestCase {
}
}
+ private func createIdentitiesInVault(count: Int) {
+ let identities = try? vault.identities()
+ for identity in identities ?? [] {
+ if let id = identity.id {
+ try? vault.deleteIdentityFor(identityId: id)
+ }
+ }
+
+ for i in 0.. Int {
+ return _identities.count
+ }
+
func identityForIdentityId(_ identityId: Int64) throws -> SecureVaultModels.Identity? {
return _identities[identityId]
}
diff --git a/Tests/BrowserServicesKitTests/Subscription/SubscriptionFeatureAvailabilityTests.swift b/Tests/BrowserServicesKitTests/Subscription/SubscriptionFeatureAvailabilityTests.swift
index ed65ea76e..a20d3dd35 100644
--- a/Tests/BrowserServicesKitTests/Subscription/SubscriptionFeatureAvailabilityTests.swift
+++ b/Tests/BrowserServicesKitTests/Subscription/SubscriptionFeatureAvailabilityTests.swift
@@ -19,7 +19,7 @@
import XCTest
import Common
import Combine
-@testable import Subscription
+import Subscription
@testable import BrowserServicesKit
final class SubscriptionFeatureAvailabilityTests: XCTestCase {
diff --git a/Tests/CommonTests/Extensions/StringExtensionTests.swift b/Tests/CommonTests/Extensions/StringExtensionTests.swift
index cc341fdfa..93989b838 100644
--- a/Tests/CommonTests/Extensions/StringExtensionTests.swift
+++ b/Tests/CommonTests/Extensions/StringExtensionTests.swift
@@ -88,4 +88,9 @@ final class StringExtensionTests: XCTestCase {
XCTAssertEqual("about:blank#navlink1".url!.absoluteString.droppingHashedSuffix(), "about:blank")
}
+ func testToIPv4Host() {
+ XCTAssertEqual("1.1.1.1".toIPv4Host, "1.1.1.1")
+ XCTAssertEqual("1".toIPv4Host, "0.0.0.1")
+ XCTAssertEqual("1.2".toIPv4Host, "1.0.0.2")
+ }
}
diff --git a/Tests/HistoryTests/HistoryCoordinatorTests.swift b/Tests/HistoryTests/HistoryCoordinatorTests.swift
index 968f203e9..4d41286d8 100644
--- a/Tests/HistoryTests/HistoryCoordinatorTests.swift
+++ b/Tests/HistoryTests/HistoryCoordinatorTests.swift
@@ -23,7 +23,6 @@ import Persistence
import Common
@testable import History
-@MainActor
class HistoryCoordinatorTests: XCTestCase {
var location: URL!
diff --git a/Tests/NavigationTests/SameDocumentNavigationTests.swift b/Tests/NavigationTests/SameDocumentNavigationTests.swift
index 4d65002e2..cc15637f6 100644
--- a/Tests/NavigationTests/SameDocumentNavigationTests.swift
+++ b/Tests/NavigationTests/SameDocumentNavigationTests.swift
@@ -442,9 +442,9 @@ class SameDocumentNavigationTests: DistributedNavigationDelegateTestsBase {
.response(Nav(action: navAct(1), .responseReceived, resp: .resp(urls.local, data.sessionStatePushClientRedirectData.count, headers: .default + ["Content-Type": "text/html"]))),
.didCommit(Nav(action: navAct(1), .responseReceived, resp: resp(0), .committed)),
- .didSameDocumentNavigation(Nav(action: NavAction(req(urls.localHashed1, [:]), .sameDocumentNavigation(.sessionStatePush), from: history[1], src: main(urls.localHashed1)), .finished), 1),
+ .didSameDocumentNavigation(Nav(action: NavAction(req(urls.localHashed1, [:]), .sameDocumentNavigation(.sessionStatePush), from: history[1], src: main(urls.localHashed1)), .finished, isCurrent: false), 1),
- .didFinish(Nav(action: navAct(1), .finished, resp: resp(0), .committed, isCurrent: false)),
+ .didFinish(Nav(action: navAct(1), .finished, resp: resp(0), .committed, isCurrent: true)),
])
}
diff --git a/Tests/NetworkProtectionTests/NetworkProtectionDeviceManagerTests.swift b/Tests/NetworkProtectionTests/NetworkProtectionDeviceManagerTests.swift
index fca554d4a..f2760c7ba 100644
--- a/Tests/NetworkProtectionTests/NetworkProtectionDeviceManagerTests.swift
+++ b/Tests/NetworkProtectionTests/NetworkProtectionDeviceManagerTests.swift
@@ -215,6 +215,7 @@ extension NetworkProtectionDeviceManager {
selectionMethod: selectionMethod,
includedRoutes: [],
excludedRoutes: [],
+ dnsSettings: .default,
isKillSwitchEnabled: false,
regenerateKey: regenerateKey
)
diff --git a/Tests/NetworkProtectionTests/Recovery/FailureRecoveryHandlerTests.swift b/Tests/NetworkProtectionTests/Recovery/FailureRecoveryHandlerTests.swift
index b7945ed8d..01968a6f2 100644
--- a/Tests/NetworkProtectionTests/Recovery/FailureRecoveryHandlerTests.swift
+++ b/Tests/NetworkProtectionTests/Recovery/FailureRecoveryHandlerTests.swift
@@ -61,6 +61,7 @@ final class FailureRecoveryHandlerTests: XCTestCase {
to: server,
includedRoutes: expectedIncludedRoutes,
excludedRoutes: expectedExcludedRoutes,
+ dnsSettings: .default,
isKillSwitchEnabled: expectedKillSwitchEnabledValue
) {_ in }
guard let spyGenerateTunnelConfiguration = deviceManager.spyGenerateTunnelConfiguration else {
@@ -128,6 +129,7 @@ final class FailureRecoveryHandlerTests: XCTestCase {
to: .mockRegisteredServer,
includedRoutes: [],
excludedRoutes: [],
+ dnsSettings: .default,
isKillSwitchEnabled: false
) {_ in }
@@ -314,6 +316,7 @@ final class FailureRecoveryHandlerTests: XCTestCase {
to: .mockRegisteredServer,
includedRoutes: [],
excludedRoutes: [],
+ dnsSettings: .default,
isKillSwitchEnabled: false
) {_ in }
}
@@ -331,6 +334,7 @@ final class FailureRecoveryHandlerTests: XCTestCase {
to: .mockRegisteredServer,
includedRoutes: [],
excludedRoutes: [],
+ dnsSettings: .default,
isKillSwitchEnabled: false
) { _ in
throw WireGuardAdapterError.startWireGuardBackend(0)
@@ -349,7 +353,7 @@ final class FailureRecoveryHandlerTests: XCTestCase {
var newConfigResult: NetworkProtectionDeviceManagement.GenerateTunnelConfigurationResult?
- await failureRecoveryHandler.attemptRecovery(to: lastServer, includedRoutes: [], excludedRoutes: [], isKillSwitchEnabled: true) { configResult in
+ await failureRecoveryHandler.attemptRecovery(to: lastServer, includedRoutes: [], excludedRoutes: [], dnsSettings: .default, isKillSwitchEnabled: true) { configResult in
newConfigResult = configResult
}
return newConfigResult
diff --git a/Tests/PrivacyDashboardTests/ToggleReportsManagerTests.swift b/Tests/PrivacyDashboardTests/ToggleReportsManagerTests.swift
new file mode 100644
index 000000000..aab0b5b49
--- /dev/null
+++ b/Tests/PrivacyDashboardTests/ToggleReportsManagerTests.swift
@@ -0,0 +1,223 @@
+//
+// ToggleReportsManagerTests.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.
+//
+
+@testable import PrivacyDashboard
+import XCTest
+
+final class MockToggleReportsFeature: ToggleReporting {
+
+ var isEnabled: Bool = true
+ var isDismissLogicEnabled: Bool = true
+ var dismissInterval: TimeInterval = 60 * 60 * 48
+ var isPromptLimitLogicEnabled: Bool = true
+ var promptInterval: TimeInterval = 60 * 60 * 48
+ var maxPromptCount: Int = 3
+
+}
+
+final class MockToggleReportsStore: ToggleReportsStoring {
+
+ var dismissedAt: Date?
+ var promptWindowStart: Date?
+ var promptCount: Int = 0
+
+}
+
+final class ToggleReportsManagerTests: XCTestCase {
+
+ // MARK: - Dismissal logic
+
+ func testShouldShowToggleReportWhenNoDismissedDate() {
+ let manager = ToggleReportsManager(feature: MockToggleReportsFeature(), store: MockToggleReportsStore())
+ XCTAssertTrue(manager.shouldShowToggleReport)
+ }
+
+ func testRecordDismissal() {
+ let store = MockToggleReportsStore()
+ var manager = ToggleReportsManager(feature: MockToggleReportsFeature(), store: store)
+
+ let now = Date()
+ manager.recordDismissal(date: now)
+ XCTAssertEqual(store.dismissedAt, now)
+ }
+
+ func testShouldShowToggleReportWhenDismissedDateIsMoreThan48HoursAgo() {
+ let store = MockToggleReportsStore()
+ let manager = ToggleReportsManager(feature: MockToggleReportsFeature(), store: store)
+ let pastDate = Date(timeIntervalSinceNow: -49 * 60 * 60) // 49 hours ago
+ store.dismissedAt = pastDate
+
+ XCTAssertTrue(manager.shouldShowToggleReport(date: Date()))
+ }
+
+ func testShouldNotShowToggleReportWhenDismissedDateIsLessThan48HoursAgo() {
+ let store = MockToggleReportsStore()
+ let manager = ToggleReportsManager(feature: MockToggleReportsFeature(), store: store)
+ let recentDate = Date(timeIntervalSinceNow: -47 * 60 * 60) // 47 hours ago
+ store.dismissedAt = recentDate
+
+ XCTAssertFalse(manager.shouldShowToggleReport(date: Date()))
+ }
+
+ // MARK: - Prompt logic
+
+ func testShouldShowToggleReportWhenPromptLimitNotReached() {
+ let store = MockToggleReportsStore()
+ store.promptCount = 2
+ let manager = ToggleReportsManager(feature: MockToggleReportsFeature(), store: store)
+
+ XCTAssertTrue(manager.shouldShowToggleReport)
+ }
+
+ func testShouldNotShowToggleReportWhenPromptLimitReachedAndPromptIntervalIsLessThan48HoursAgo() {
+ let store = MockToggleReportsStore()
+ store.promptCount = 3
+ store.promptWindowStart = Date().addingTimeInterval(-24 * 60 * 60)
+ let manager = ToggleReportsManager(feature: MockToggleReportsFeature(), store: store)
+
+ XCTAssertFalse(manager.shouldShowToggleReport)
+ }
+
+ func testShouldShowToggleReportWhenPromptLimitReachedButPromptIntervalIsMoreThan48HoursAgo() {
+ let store = MockToggleReportsStore()
+ store.promptCount = 3
+ store.promptWindowStart = Date().addingTimeInterval(-72 * 60 * 60)
+ let manager = ToggleReportsManager(feature: MockToggleReportsFeature(), store: store)
+
+ XCTAssertTrue(manager.shouldShowToggleReport)
+ }
+
+ // MARK: - Rolling window
+
+ func testRecordPromptWhenWithinWindowShouldIncrementCount() {
+ let store = MockToggleReportsStore()
+ // Set initial window start within the last 48 hours
+ let windowStart = Date().addingTimeInterval(-24 * 60 * 60)
+ store.promptWindowStart = windowStart
+ store.promptCount = 1
+
+ var manager = ToggleReportsManager(feature: MockToggleReportsFeature(), store: store)
+ // Record another prompt within the same window
+ manager.recordPrompt(date: Date())
+
+ XCTAssertEqual(store.promptCount, 2)
+ XCTAssertEqual(store.promptWindowStart, windowStart)
+ }
+
+ func testRecordPromptWhenOutsideWindowShouldResetCount() {
+ let store = MockToggleReportsStore()
+ // Set initial window start more than 48 hours ago
+ store.promptWindowStart = Date().addingTimeInterval(-72 * 60 * 60)
+ store.promptCount = 2
+
+ var manager = ToggleReportsManager(feature: MockToggleReportsFeature(), store: store)
+ // Record prompt outside the previous window
+ let now = Date()
+ manager.recordPrompt(date: now)
+
+ XCTAssertEqual(store.promptCount, 1)
+ XCTAssertNotNil(store.promptWindowStart)
+ XCTAssertEqual(store.promptWindowStart, now)
+ }
+
+ func testRecordPromptWhenNoWindowShouldStartNewWindow() {
+ let store = MockToggleReportsStore()
+ // No initial window start
+ store.promptWindowStart = nil
+ store.promptCount = 0
+
+ var manager = ToggleReportsManager(feature: MockToggleReportsFeature(), store: store)
+ // Record prompt without previous window
+ let now = Date()
+ manager.recordPrompt(date: now)
+
+ XCTAssertEqual(store.promptCount, 1)
+ XCTAssertNotNil(store.promptWindowStart)
+ XCTAssertEqual(store.promptWindowStart, now)
+ }
+
+ // MARK: - Combination of both prompts and dismissal logic
+
+ func testShouldNotShowToggleReportWhenDismissedLessThan48HoursAndPromptLimitNotReached() {
+ let store = MockToggleReportsStore()
+ store.dismissedAt = Date().addingTimeInterval(-24 * 60 * 60)
+ store.promptCount = 2
+ let manager = ToggleReportsManager(feature: MockToggleReportsFeature(), store: store)
+
+ XCTAssertFalse(manager.shouldShowToggleReport(date: Date()))
+ }
+
+ func testShouldNotShowToggleReportWhenDismissedLessThan48HoursAndPromptLimitReached() {
+ let store = MockToggleReportsStore()
+ store.dismissedAt = Date().addingTimeInterval(-24 * 60 * 60)
+ store.promptCount = 3
+ let manager = ToggleReportsManager(feature: MockToggleReportsFeature(), store: store)
+
+ XCTAssertFalse(manager.shouldShowToggleReport(date: Date()))
+ }
+
+ func testShouldNotShowToggleReportWhenDismissedMoreThan48HoursAndPromptLimitReached() {
+ let store = MockToggleReportsStore()
+ store.dismissedAt = Date().addingTimeInterval(-72 * 60 * 60)
+ store.promptWindowStart = Date().addingTimeInterval(-24 * 60 * 60)
+ store.promptCount = 3
+ let manager = ToggleReportsManager(feature: MockToggleReportsFeature(), store: store)
+
+ XCTAssertFalse(manager.shouldShowToggleReport(date: Date()))
+ }
+
+ func testShouldShowToggleReportWhenDismissedMoreThan48HoursAndPromptLimitNotReached() {
+ let store = MockToggleReportsStore()
+ store.dismissedAt = Date().addingTimeInterval(-72 * 60 * 60)
+ store.promptCount = 2
+ let manager = ToggleReportsManager(feature: MockToggleReportsFeature(), store: store)
+
+ XCTAssertTrue(manager.shouldShowToggleReport(date: Date()))
+ }
+
+ // MARK: - Feature
+
+ func testShouldNotShowToggleReportWhenFeatureDisabled() {
+ let feature = MockToggleReportsFeature()
+ feature.isEnabled = false
+ let manager = ToggleReportsManager(feature: feature, store: MockToggleReportsStore())
+ XCTAssertFalse(manager.shouldShowToggleReport)
+ }
+
+ func testShouldShowToggleReportWhenPromptLimitReachedButPromptLimitLogicDisabled() {
+ let store = MockToggleReportsStore()
+ store.promptWindowStart = Date().addingTimeInterval(-24 * 60 * 60)
+ store.promptCount = 5
+ let feature = MockToggleReportsFeature()
+ let manager = ToggleReportsManager(feature: feature, store: store)
+ XCTAssertFalse(manager.shouldShowToggleReport)
+ feature.isPromptLimitLogicEnabled = false
+ XCTAssertTrue(manager.shouldShowToggleReport)
+ }
+
+ func testShouldShowToggleReportWhenDismissedDateIsLessThan48HoursAgoButDismissLogicDisabled() {
+ let store = MockToggleReportsStore()
+ store.dismissedAt = Date().addingTimeInterval(-47 * 60 * 60)
+ let feature = MockToggleReportsFeature()
+ let manager = ToggleReportsManager(feature: feature, store: store)
+ XCTAssertFalse(manager.shouldShowToggleReport)
+ feature.isDismissLogicEnabled = false
+ XCTAssertTrue(manager.shouldShowToggleReport)
+ }
+
+}
diff --git a/Tests/SubscriptionTests/API/AuthEndpointServiceTests.swift b/Tests/SubscriptionTests/API/AuthEndpointServiceTests.swift
new file mode 100644
index 000000000..5759edd57
--- /dev/null
+++ b/Tests/SubscriptionTests/API/AuthEndpointServiceTests.swift
@@ -0,0 +1,71 @@
+//
+// AuthEndpointServiceTests.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 AuthEndpointServiceTests: XCTestCase {
+
+ override func setUpWithError() throws {
+ // Put setup code here. This method is called before the invocation of each test method in the class.
+ }
+
+ override func tearDownWithError() throws {
+ // Put teardown code here. This method is called after the invocation of each test method in the class.
+ }
+
+ func testCreateAccountSuccess() async throws {
+ let token = "someToken"
+ let externalID = "id?"
+ let status = "noidea"
+ let mockedCreateAccountResponse = CreateAccountResponse(authToken: token, externalID: externalID, status: status)
+ let apiService = APIServiceMock(mockAuthHeaders: ["Authorization": "Bearer " + token],
+ mockAPICallSuccessResult: mockedCreateAccountResponse)
+ let service = DefaultAuthEndpointService(currentServiceEnvironment: .staging,
+ apiService: apiService)
+ let result = await service.createAccount(emailAccessToken: token)
+ switch result {
+ case .success(let success):
+ XCTAssertEqual(success.authToken, token)
+ XCTAssertEqual(success.externalID, externalID)
+ XCTAssertEqual(success.status, status)
+ case .failure:
+ XCTFail("Unexpected failure")
+ }
+ }
+
+ func testCreateAccountFailure() async throws {
+ let token = "someToken"
+ let apiService = APIServiceMock(mockAuthHeaders: ["Authorization": "Bearer " + token],
+ mockAPICallError: .encodingError)
+ let service = DefaultAuthEndpointService(currentServiceEnvironment: .staging,
+ apiService: apiService)
+ let result = await service.createAccount(emailAccessToken: token)
+ switch result {
+ case .success:
+ XCTFail("Unexpected success")
+ case .failure(let failure):
+ switch failure {
+ case APIServiceError.encodingError: break
+ default:
+ XCTFail("Wrong error")
+ }
+ }
+ }
+}
diff --git a/Tests/SubscriptionTests/API/Models/EntitlementTests.swift b/Tests/SubscriptionTests/API/Models/EntitlementTests.swift
new file mode 100644
index 000000000..c6fa25087
--- /dev/null
+++ b/Tests/SubscriptionTests/API/Models/EntitlementTests.swift
@@ -0,0 +1,41 @@
+//
+// EntitlementTests.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 EntitlementTests: XCTestCase {
+
+ override func setUpWithError() throws {
+ // Put setup code here. This method is called before the invocation of each test method in the class.
+ }
+
+ override func tearDownWithError() throws {
+ // Put teardown code here. This method is called after the invocation of each test method in the class.
+ }
+
+ func testEquality() throws {
+ XCTAssertEqual(Entitlement(product: .dataBrokerProtection), Entitlement(product: .dataBrokerProtection))
+ XCTAssertNotEqual(Entitlement(product: .dataBrokerProtection), Entitlement(product: .networkProtection))
+ }
+
+ func testDecoding() throws {
+ // Decode Entitlement
+ }
+}
diff --git a/Tests/SubscriptionTests/API/Models/SubscriptionTests.swift b/Tests/SubscriptionTests/API/Models/SubscriptionTests.swift
new file mode 100644
index 000000000..aacbe8a45
--- /dev/null
+++ b/Tests/SubscriptionTests/API/Models/SubscriptionTests.swift
@@ -0,0 +1,62 @@
+//
+// 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 {
+
+ override func setUpWithError() throws {
+ // Put setup code here. This method is called before the invocation of each test method in the class.
+ }
+
+ override func tearDownWithError() throws {
+ // Put teardown code here. This method is called after the invocation of each test method in the class.
+ }
+
+ func testEquality() throws {
+ let a = DDGSubscription(productId: "1",
+ name: "a",
+ billingPeriod: .monthly,
+ startedAt: Date(timeIntervalSince1970: 1000),
+ expiresOrRenewsAt: Date(timeIntervalSince1970: 2000),
+ platform: .apple,
+ status: .autoRenewable)
+ let b = DDGSubscription(productId: "1",
+ name: "a",
+ billingPeriod: .monthly,
+ startedAt: Date(timeIntervalSince1970: 1000),
+ expiresOrRenewsAt: Date(timeIntervalSince1970: 2000),
+ platform: .apple,
+ status: .autoRenewable)
+ let c = DDGSubscription(productId: "2",
+ name: "a",
+ billingPeriod: .monthly,
+ startedAt: Date(timeIntervalSince1970: 1000),
+ expiresOrRenewsAt: Date(timeIntervalSince1970: 2000),
+ platform: .apple,
+ status: .autoRenewable)
+ XCTAssertEqual(a, b)
+ XCTAssertNotEqual(a, c)
+ }
+
+ func testDecoding() throws {
+ // Decode
+ }
+}
diff --git a/Tests/SubscriptionTests/API/SubscriptionEndpointServiceTests.swift b/Tests/SubscriptionTests/API/SubscriptionEndpointServiceTests.swift
new file mode 100644
index 000000000..2b7fa61cb
--- /dev/null
+++ b/Tests/SubscriptionTests/API/SubscriptionEndpointServiceTests.swift
@@ -0,0 +1,61 @@
+//
+// SubscriptionEndpointServiceTests.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 SubscriptionEndpointServiceTests: XCTestCase {
+
+ override func setUpWithError() throws {
+ // Put setup code here. This method is called before the invocation of each test method in the class.
+ }
+
+ override func tearDownWithError() throws {
+ // Put teardown code here. This method is called after the invocation of each test method in the class.
+ }
+
+ func testGetSubscriptionSuccessNoCache() async throws {
+ let token = "someToken"
+ let subscription = DDGSubscription(productId: "productID",
+ name: "name",
+ billingPeriod: .monthly,
+ startedAt: Date.yearAgo,
+ expiresOrRenewsAt: Date.aYearFromNow,
+ platform: .apple,
+ status: .autoRenewable)
+ let apiService = APIServiceMock(mockAuthHeaders: ["Authorization": "Bearer " + token],
+ mockAPICallSuccessResult: subscription)
+ let service = DefaultSubscriptionEndpointService(currentServiceEnvironment: .staging,
+ apiService: apiService)
+ switch await service.getSubscription(accessToken: token, cachePolicy: .reloadIgnoringLocalCacheData) {
+ case .success(let success):
+ XCTAssertEqual(subscription, success)
+ case .failure:
+ XCTFail("Unexpected failure")
+ }
+ }
+
+ func testGetSubscriptionSuccessCache() async throws {
+ // Implement
+ }
+
+ func testGetSubscriptionFailure() async throws {
+ // Implement
+ }
+}
diff --git a/Tests/SubscriptionTests/Flows/AppStoreAccountManagementFlowTests.swift b/Tests/SubscriptionTests/Flows/AppStoreAccountManagementFlowTests.swift
new file mode 100644
index 000000000..ddf695eed
--- /dev/null
+++ b/Tests/SubscriptionTests/Flows/AppStoreAccountManagementFlowTests.swift
@@ -0,0 +1,48 @@
+//
+// AppStoreAccountManagementFlowTests.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 AppStoreAccountManagementFlowTests: XCTestCase {
+
+ override func setUpWithError() throws {
+ // Put setup code here. This method is called before the invocation of each test method in the class.
+ }
+
+ override func tearDownWithError() throws {
+ // Put teardown code here. This method is called after the invocation of each test method in the class.
+ }
+
+ func testRefreshAuthTokenIfNeededSuccess() async throws {
+ let authEndpointService = SubscriptionMockFactory.authEndpointService
+ let storePurchaseManager = SubscriptionMockFactory.storePurchaseManager
+ let accountManager = SubscriptionMockFactory.accountManager
+
+ let flow = DefaultAppStoreAccountManagementFlow(authEndpointService: authEndpointService,
+ storePurchaseManager: storePurchaseManager,
+ accountManager: accountManager)
+ switch await flow.refreshAuthTokenIfNeeded() {
+ case .success:
+ break
+ case .failure(let error):
+ XCTFail("Unexpected failure: \(error.localizedDescription)")
+ }
+ }
+}
diff --git a/Tests/SubscriptionTests/Flows/AppStorePurchaseFlowTests.swift b/Tests/SubscriptionTests/Flows/AppStorePurchaseFlowTests.swift
new file mode 100644
index 000000000..532d4a113
--- /dev/null
+++ b/Tests/SubscriptionTests/Flows/AppStorePurchaseFlowTests.swift
@@ -0,0 +1,55 @@
+//
+// AppStorePurchaseFlowTests.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 AppStorePurchaseFlowTests: XCTestCase {
+
+ override func setUpWithError() throws {
+ // Put setup code here. This method is called before the invocation of each test method in the class.
+ }
+
+ override func tearDownWithError() throws {
+ // Put teardown code here. This method is called after the invocation of each test method in the class.
+ }
+
+ func testPurchaseSubscriptionSuccess() async throws {
+ let subscriptionEndpointService = SubscriptionMockFactory.subscriptionEndpointService
+ let storePurchaseManager = SubscriptionMockFactory.storePurchaseManager
+ let appStoreRestoreFlow = SubscriptionMockFactory.appStoreRestoreFlow
+ appStoreRestoreFlow.restoreAccountFromPastPurchaseResult = .failure(.missingAccountOrTransactions)
+ let authEndpointService = SubscriptionMockFactory.authEndpointService
+ let accountManager = SubscriptionMockFactory.accountManager
+
+ let flow = DefaultAppStorePurchaseFlow(subscriptionEndpointService: subscriptionEndpointService,
+ storePurchaseManager: storePurchaseManager,
+ accountManager: accountManager,
+ appStoreRestoreFlow: appStoreRestoreFlow,
+ authEndpointService: authEndpointService)
+
+ switch await flow.purchaseSubscription(with: SubscriptionMockFactory.subscription.productId,
+ emailAccessToken: SubscriptionMockFactory.authToken) {
+ case .success:
+ break
+ case .failure(let error):
+ XCTFail("Unexpected failure: \(error.localizedDescription)")
+ }
+ }
+}
diff --git a/Tests/SubscriptionTests/Flows/AppStoreRestoreFlowTests.swift b/Tests/SubscriptionTests/Flows/AppStoreRestoreFlowTests.swift
new file mode 100644
index 000000000..ba3a1f653
--- /dev/null
+++ b/Tests/SubscriptionTests/Flows/AppStoreRestoreFlowTests.swift
@@ -0,0 +1,50 @@
+//
+// AppStoreRestoreFlowTests.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 AppStoreRestoreFlowTests: XCTestCase {
+
+ override func setUpWithError() throws {
+ // Put setup code here. This method is called before the invocation of each test method in the class.
+ }
+
+ override func tearDownWithError() throws {
+ // Put teardown code here. This method is called after the invocation of each test method in the class.
+ }
+
+ func testRestoreAccountFromPastPurchaseSuccess() async throws {
+
+ var storePurchaseManager = SubscriptionMockFactory.storePurchaseManager
+ storePurchaseManager.mostRecentTransactionResult = "eyJhbGciOiJFUzI1NiIsIng1YyI6WyJNSUlFTURDQ0E3YWdBd0lCQWdJUWZUbGZkMGZOdkZXdnpDMVlJQU5zWGpBS0JnZ3Foa2pPUFFRREF6QjFNVVF3UWdZRFZRUURERHRCY0hCc1pTQlhiM0pzWkhkcFpHVWdSR1YyWld4dmNHVnlJRkpsYkdGMGFXOXVjeUJEWlhKMGFXWnBZMkYwYVc5dUlFRjFkR2h2Y21sMGVURUxNQWtHQTFVRUN3d0NSell4RXpBUkJnTlZCQW9NQ2tGd2NHeGxJRWx1WXk0eEN6QUpCZ05WQkFZVEFsVlRNQjRYRFRJek1Ea3hNakU1TlRFMU0xb1hEVEkxTVRBeE1URTVOVEUxTWxvd2daSXhRREErQmdOVkJBTU1OMUJ5YjJRZ1JVTkRJRTFoWXlCQmNIQWdVM1J2Y21VZ1lXNWtJR2xVZFc1bGN5QlRkRzl5WlNCU1pXTmxhWEIwSUZOcFoyNXBibWN4TERBcUJnTlZCQXNNSTBGd2NHeGxJRmR2Y214a2QybGtaU0JFWlhabGJHOXdaWElnVW1Wc1lYUnBiMjV6TVJNd0VRWURWUVFLREFwQmNIQnNaU0JKYm1NdU1Rc3dDUVlEVlFRR0V3SlZVekJaTUJNR0J5cUdTTTQ5QWdFR0NDcUdTTTQ5QXdFSEEwSUFCRUZFWWUvSnFUcXlRdi9kdFhrYXVESENTY1YxMjlGWVJWLzB4aUIyNG5DUWt6UWYzYXNISk9OUjVyMFJBMGFMdko0MzJoeTFTWk1vdXZ5ZnBtMjZqWFNqZ2dJSU1JSUNCREFNQmdOVkhSTUJBZjhFQWpBQU1COEdBMVVkSXdRWU1CYUFGRDh2bENOUjAxREptaWc5N2JCODVjK2xrR0taTUhBR0NDc0dBUVVGQndFQkJHUXdZakF0QmdnckJnRUZCUWN3QW9ZaGFIUjBjRG92TDJObGNuUnpMbUZ3Y0d4bExtTnZiUzkzZDJSeVp6WXVaR1Z5TURFR0NDc0dBUVVGQnpBQmhpVm9kSFJ3T2k4dmIyTnpjQzVoY0hCc1pTNWpiMjB2YjJOemNEQXpMWGQzWkhKbk5qQXlNSUlCSGdZRFZSMGdCSUlCRlRDQ0FSRXdnZ0VOQmdvcWhraUc5Mk5rQlFZQk1JSCtNSUhEQmdnckJnRUZCUWNDQWpDQnRneUJzMUpsYkdsaGJtTmxJRzl1SUhSb2FYTWdZMlZ5ZEdsbWFXTmhkR1VnWW5rZ1lXNTVJSEJoY25SNUlHRnpjM1Z0WlhNZ1lXTmpaWEIwWVc1alpTQnZaaUIwYUdVZ2RHaGxiaUJoY0hCc2FXTmhZbXhsSUhOMFlXNWtZWEprSUhSbGNtMXpJR0Z1WkNCamIyNWthWFJwYjI1eklHOW1JSFZ6WlN3Z1kyVnlkR2xtYVdOaGRHVWdjRzlzYVdONUlHRnVaQ0JqWlhKMGFXWnBZMkYwYVc5dUlIQnlZV04wYVdObElITjBZWFJsYldWdWRITXVNRFlHQ0NzR0FRVUZCd0lCRmlwb2RIUndPaTh2ZDNkM0xtRndjR3hsTG1OdmJTOWpaWEowYVdacFkyRjBaV0YxZEdodmNtbDBlUzh3SFFZRFZSME9CQllFRkFNczhQanM2VmhXR1FsekUyWk9FK0dYNE9vL01BNEdBMVVkRHdFQi93UUVBd0lIZ0RBUUJnb3Foa2lHOTJOa0Jnc0JCQUlGQURBS0JnZ3Foa2pPUFFRREF3Tm9BREJsQWpFQTh5Uk5kc2twNTA2REZkUExnaExMSndBdjVKOGhCR0xhSThERXhkY1BYK2FCS2pqTzhlVW85S3BmcGNOWVVZNVlBakFQWG1NWEVaTCtRMDJhZHJtbXNoTnh6M05uS20rb3VRd1U3dkJUbjBMdmxNN3ZwczJZc2xWVGFtUllMNGFTczVrPSIsIk1JSURGakNDQXB5Z0F3SUJBZ0lVSXNHaFJ3cDBjMm52VTRZU3ljYWZQVGp6Yk5jd0NnWUlLb1pJemowRUF3TXdaekViTUJrR0ExVUVBd3dTUVhCd2JHVWdVbTl2ZENCRFFTQXRJRWN6TVNZd0pBWURWUVFMREIxQmNIQnNaU0JEWlhKMGFXWnBZMkYwYVc5dUlFRjFkR2h2Y21sMGVURVRNQkVHQTFVRUNnd0tRWEJ3YkdVZ1NXNWpMakVMTUFrR0ExVUVCaE1DVlZNd0hoY05NakV3TXpFM01qQXpOekV3V2hjTk16WXdNekU1TURBd01EQXdXakIxTVVRd1FnWURWUVFERER0QmNIQnNaU0JYYjNKc1pIZHBaR1VnUkdWMlpXeHZjR1Z5SUZKbGJHRjBhVzl1Y3lCRFpYSjBhV1pwWTJGMGFXOXVJRUYxZEdodmNtbDBlVEVMTUFrR0ExVUVDd3dDUnpZeEV6QVJCZ05WQkFvTUNrRndjR3hsSUVsdVl5NHhDekFKQmdOVkJBWVRBbFZUTUhZd0VBWUhLb1pJemowQ0FRWUZLNEVFQUNJRFlnQUVic1FLQzk0UHJsV21aWG5YZ3R4emRWSkw4VDBTR1luZ0RSR3BuZ24zTjZQVDhKTUViN0ZEaTRiQm1QaENuWjMvc3E2UEYvY0djS1hXc0w1dk90ZVJoeUo0NXgzQVNQN2NPQithYW85MGZjcHhTdi9FWkZibmlBYk5nWkdoSWhwSW80SDZNSUgzTUJJR0ExVWRFd0VCL3dRSU1BWUJBZjhDQVFBd0h3WURWUjBqQkJnd0ZvQVV1N0Rlb1ZnemlKcWtpcG5ldnIzcnI5ckxKS3N3UmdZSUt3WUJCUVVIQVFFRU9qQTRNRFlHQ0NzR0FRVUZCekFCaGlwb2RIUndPaTh2YjJOemNDNWhjSEJzWlM1amIyMHZiMk56Y0RBekxXRndjR3hsY205dmRHTmhaek13TndZRFZSMGZCREF3TGpBc29DcWdLSVltYUhSMGNEb3ZMMk55YkM1aGNIQnNaUzVqYjIwdllYQndiR1Z5YjI5MFkyRm5NeTVqY213d0hRWURWUjBPQkJZRUZEOHZsQ05SMDFESm1pZzk3YkI4NWMrbGtHS1pNQTRHQTFVZER3RUIvd1FFQXdJQkJqQVFCZ29xaGtpRzkyTmtCZ0lCQkFJRkFEQUtCZ2dxaGtqT1BRUURBd05vQURCbEFqQkFYaFNxNUl5S29nTUNQdHc0OTBCYUI2NzdDYUVHSlh1ZlFCL0VxWkdkNkNTamlDdE9udU1UYlhWWG14eGN4ZmtDTVFEVFNQeGFyWlh2TnJreFUzVGtVTUkzM3l6dkZWVlJUNHd4V0pDOTk0T3NkY1o0K1JHTnNZRHlSNWdtZHIwbkRHZz0iLCJNSUlDUXpDQ0FjbWdBd0lCQWdJSUxjWDhpTkxGUzVVd0NnWUlLb1pJemowRUF3TXdaekViTUJrR0ExVUVBd3dTUVhCd2JHVWdVbTl2ZENCRFFTQXRJRWN6TVNZd0pBWURWUVFMREIxQmNIQnNaU0JEWlhKMGFXWnBZMkYwYVc5dUlFRjFkR2h2Y21sMGVURVRNQkVHQTFVRUNnd0tRWEJ3YkdVZ1NXNWpMakVMTUFrR0ExVUVCaE1DVlZNd0hoY05NVFF3TkRNd01UZ3hPVEEyV2hjTk16a3dORE13TVRneE9UQTJXakJuTVJzd0dRWURWUVFEREJKQmNIQnNaU0JTYjI5MElFTkJJQzBnUnpNeEpqQWtCZ05WQkFzTUhVRndjR3hsSUVObGNuUnBabWxqWVhScGIyNGdRWFYwYUc5eWFYUjVNUk13RVFZRFZRUUtEQXBCY0hCc1pTQkpibU11TVFzd0NRWURWUVFHRXdKVlV6QjJNQkFHQnlxR1NNNDlBZ0VHQlN1QkJBQWlBMklBQkpqcEx6MUFjcVR0a3lKeWdSTWMzUkNWOGNXalRuSGNGQmJaRHVXbUJTcDNaSHRmVGpqVHV4eEV0WC8xSDdZeVlsM0o2WVJiVHpCUEVWb0EvVmhZREtYMUR5eE5CMGNUZGRxWGw1ZHZNVnp0SzUxN0lEdll1VlRaWHBta09sRUtNYU5DTUVBd0hRWURWUjBPQkJZRUZMdXczcUZZTTRpYXBJcVozcjY5NjYvYXl5U3JNQThHQTFVZEV3RUIvd1FGTUFNQkFmOHdEZ1lEVlIwUEFRSC9CQVFEQWdFR01Bb0dDQ3FHU000OUJBTURBMmdBTUdVQ01RQ0Q2Y0hFRmw0YVhUUVkyZTN2OUd3T0FFWkx1Tit5UmhIRkQvM21lb3locG12T3dnUFVuUFdUeG5TNGF0K3FJeFVDTUcxbWloREsxQTNVVDgyTlF6NjBpbU9sTTI3amJkb1h0MlFmeUZNbStZaGlkRGtMRjF2TFVhZ002QmdENTZLeUtBPT0iXX0.eyJ0cmFuc2FjdGlvbklkIjoiMjAwMDAwMDYzNzEzMjQ2MSIsIm9yaWdpbmFsVHJhbnNhY3Rpb25JZCI6IjIwMDAwMDA1NDU3MzkzODQiLCJ3ZWJPcmRlckxpbmVJdGVtSWQiOiIyMDAwMDAwMDY1MzMwMjg5IiwiYnVuZGxlSWQiOiJjb20uZHVja2R1Y2tnby5tYWNvcy5icm93c2VyLmRlYnVnIiwicHJvZHVjdElkIjoic3Vic2NyaXB0aW9uLjFtb250aCIsInN1YnNjcmlwdGlvbkdyb3VwSWRlbnRpZmllciI6IjIxMzQxODQ2IiwicHVyY2hhc2VEYXRlIjoxNzE5MjM2MDQ1MDAwLCJvcmlnaW5hbFB1cmNoYXNlRGF0ZSI6MTcxMDE3OTQwNTAwMCwiZXhwaXJlc0RhdGUiOjE3MTkyMzYzNDUwMDAsInF1YW50aXR5IjoxLCJ0eXBlIjoiQXV0by1SZW5ld2FibGUgU3Vic2NyaXB0aW9uIiwiZGV2aWNlVmVyaWZpY2F0aW9uIjoiMzhyMkpPVFpHV3lMMnZiNDBKQktsYmpzUzVqOG15a01Dc3VFV3c2MEd5NWl1RlVLeW0rTHpJeGM3VUpjVXRkKyIsImRldmljZVZlcmlmaWNhdGlvbk5vbmNlIjoiZTUwOTUwNzctYmQ4My00MWJjLWIxNDItZGJkMzUxODBhYTE1IiwiYXBwQWNjb3VudFRva2VuIjoiNzIyMzYzMmMtNWI2ZC00Njk0LTg0OTUtMDA2N2IxMzBiOWQyIiwiaW5BcHBPd25lcnNoaXBUeXBlIjoiUFVSQ0hBU0VEIiwic2lnbmVkRGF0ZSI6MTcxOTQxMjU1MDkzNywiZW52aXJvbm1lbnQiOiJTYW5kYm94IiwidHJhbnNhY3Rpb25SZWFzb24iOiJSRU5FV0FMIiwic3RvcmVmcm9udCI6IlVTQSIsInN0b3JlZnJvbnRJZCI6IjE0MzQ0MSIsInByaWNlIjo5OTkwLCJjdXJyZW5jeSI6IlVTRCJ9.XMCzyP0gvSCXmOci3x9PMW3vP_A1F9ekZ_8M9j7cFR0klBGondmZjiCTw0t-gRtaCWN9jSvvoIWx2LPkHATAlA"
+
+ let accountManager = AccountManagerMock(email: "5p2d4sx1@duck.com", externalID: "something")
+ let appStoreRestoreFlow = DefaultAppStoreRestoreFlow(accountManager: accountManager,
+ storePurchaseManager: storePurchaseManager,
+ subscriptionEndpointService: SubscriptionMockFactory.subscriptionEndpointService,
+ authEndpointService: SubscriptionMockFactory.authEndpointService)
+ switch await appStoreRestoreFlow.restoreAccountFromPastPurchase() {
+ case .success:
+ break
+ case .failure(let error):
+ XCTFail("Unexpected failure: \(error)")
+ }
+ }
+}
diff --git a/Tests/SubscriptionTests/SubscriptionTests.swift b/Tests/SubscriptionTests/Flows/Models/PurchaseUpdateTests.swift
similarity index 61%
rename from Tests/SubscriptionTests/SubscriptionTests.swift
rename to Tests/SubscriptionTests/Flows/Models/PurchaseUpdateTests.swift
index 6225b8df6..b01628132 100644
--- a/Tests/SubscriptionTests/SubscriptionTests.swift
+++ b/Tests/SubscriptionTests/Flows/Models/PurchaseUpdateTests.swift
@@ -1,5 +1,5 @@
//
-// SubscriptionTests.swift
+// PurchaseUpdateTests.swift
//
// Copyright © 2024 DuckDuckGo. All rights reserved.
//
@@ -20,6 +20,17 @@ import XCTest
@testable import Subscription
import SubscriptionTestingUtilities
-final class SubscriptionTests: XCTestCase {
+final class PurchaseUpdateTests: XCTestCase {
+ override func setUpWithError() throws {
+ // Put setup code here. This method is called before the invocation of each test method in the class.
+ }
+
+ override func tearDownWithError() throws {
+ // Put teardown code here. This method is called after the invocation of each test method in the class.
+ }
+
+ func testCodable() throws {
+
+ }
}
diff --git a/Tests/SubscriptionTests/Flows/Models/SubscriptionOptionsTests.swift b/Tests/SubscriptionTests/Flows/Models/SubscriptionOptionsTests.swift
new file mode 100644
index 000000000..8f6070609
--- /dev/null
+++ b/Tests/SubscriptionTests/Flows/Models/SubscriptionOptionsTests.swift
@@ -0,0 +1,36 @@
+//
+// SubscriptionOptionsTests.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 SubscriptionOptionsTests: XCTestCase {
+
+ override func setUpWithError() throws {
+ // Put setup code here. This method is called before the invocation of each test method in the class.
+ }
+
+ override func tearDownWithError() throws {
+ // Put teardown code here. This method is called after the invocation of each test method in the class.
+ }
+
+ func testCodable() throws {
+
+ }
+}
diff --git a/Tests/SubscriptionTests/Flows/StripePurchaseFlowTests.swift b/Tests/SubscriptionTests/Flows/StripePurchaseFlowTests.swift
new file mode 100644
index 000000000..e4d8781fe
--- /dev/null
+++ b/Tests/SubscriptionTests/Flows/StripePurchaseFlowTests.swift
@@ -0,0 +1,58 @@
+//
+// StripePurchaseFlowTests.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 StripePurchaseFlowTests: XCTestCase {
+
+ override func setUpWithError() throws {
+ // Put setup code here. This method is called before the invocation of each test method in the class.
+ }
+
+ override func tearDownWithError() throws {
+ // Put teardown code here. This method is called after the invocation of each test method in the class.
+ }
+
+ func testSubscriptionOptionsSuccess() async throws {
+ let subscriptionEndpointService = SubscriptionEndpointServiceMock(getSubscriptionResult: nil,
+ getProductsResult: .success(SubscriptionMockFactory.productsItems),
+ getCustomerPortalURLResult: nil,
+ confirmPurchaseResult: nil)
+ let authEndpointService = AuthEndpointServiceMock(accessTokenResult: nil,
+ validateTokenResult: nil,
+ createAccountResult: nil,
+ storeLoginResult: nil)
+ let stripePurchaseFlow = DefaultStripePurchaseFlow(subscriptionEndpointService: subscriptionEndpointService,
+ authEndpointService: authEndpointService,
+ accountManager: AccountManagerMock())
+ switch await stripePurchaseFlow.subscriptionOptions() {
+ case .success(let success):
+ XCTAssertEqual(success.platform, SubscriptionPlatformName.stripe.rawValue)
+ XCTAssertEqual(success.options.count, 1)
+ XCTAssertEqual(success.features.count, 7)
+ let allNames = success.features.compactMap({ feature in feature.name})
+ for name in SubscriptionFeatureName.allCases {
+ XCTAssertTrue(allNames.contains(name.rawValue))
+ }
+ case .failure(let failure):
+ XCTFail("Unexpected failure: \(failure)")
+ }
+ }
+}
diff --git a/Tests/SubscriptionTests/Managers/AccountManagerTests.swift b/Tests/SubscriptionTests/Managers/AccountManagerTests.swift
new file mode 100644
index 000000000..4a466c2c0
--- /dev/null
+++ b/Tests/SubscriptionTests/Managers/AccountManagerTests.swift
@@ -0,0 +1,55 @@
+//
+// AccountManagerTests.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
+import Common
+
+final class AccountManagerTests: XCTestCase {
+
+ var userDefaults: UserDefaults!
+ let testGroupName = "com.ddg.unitTests.AccountManagerTests"
+
+ override func setUpWithError() throws {
+ userDefaults = UserDefaults(suiteName: testGroupName)!
+ }
+
+ override func tearDownWithError() throws {
+ userDefaults.removePersistentDomain(forName: testGroupName)
+ }
+
+ func testExample() throws {
+ let accessToken = "someAccessToken"
+ let storage = AccountKeychainStorageMock()
+ let accessTokenStorage = SubscriptionTokenKeychainStorageMock()
+ let entitlementsCache = UserDefaultsCache<[Entitlement]>(userDefaults: userDefaults,
+ key: UserDefaultsCacheKey.subscriptionEntitlements,
+ settings: UserDefaultsCacheSettings(defaultExpirationInterval: .minutes(20)))
+ let accountManager = DefaultAccountManager(storage: storage,
+ accessTokenStorage: accessTokenStorage,
+ entitlementsCache: entitlementsCache,
+ subscriptionEndpointService: SubscriptionMockFactory.subscriptionEndpointService,
+ authEndpointService: SubscriptionMockFactory.authEndpointService)
+
+ accountManager.storeAccount(token: accessToken, email: SubscriptionMockFactory.email, externalID: SubscriptionMockFactory.externalId)
+ XCTAssertEqual(accessTokenStorage.accessToken, accessToken)
+ XCTAssertEqual(storage.email, SubscriptionMockFactory.email)
+ XCTAssertEqual(storage.externalID, SubscriptionMockFactory.externalId)
+ }
+}
diff --git a/Tests/SubscriptionTests/Managers/StorePurchaseManagerTests.swift b/Tests/SubscriptionTests/Managers/StorePurchaseManagerTests.swift
new file mode 100644
index 000000000..3158e96b7
--- /dev/null
+++ b/Tests/SubscriptionTests/Managers/StorePurchaseManagerTests.swift
@@ -0,0 +1,52 @@
+//
+// StorePurchaseManagerTests.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
+import StoreKit
+import StoreKitTest
+
+final class StorePurchaseManagerTests: XCTestCase {
+
+ override func setUpWithError() throws {
+ // Put setup code here. This method is called before the invocation of each test method in the class.
+ }
+
+ override func tearDownWithError() throws {
+ // Put teardown code here. This method is called after the invocation of each test method in the class.
+ }
+
+ func testExample() async throws {
+
+ // Option 1: make the `SubscriptionsTestConfig.storekit` to work as explained in https://developer.apple.com/documentation/xcode/setting-up-storekit-testing-in-xcode and https://developer.apple.com/videos/play/wwdc2020/10659/, then test `DefaultStorePurchaseManager` as it is
+ /*
+ let manager = DefaultStorePurchaseManager()
+ try await manager.syncAppleIDAccount()
+ XCTAssert(manager.availableProducts.count == 2)
+ */
+
+ // Option 2: create a protocol abstracting `StoreKit` from `DefaultStorePurchaseManager` and create a mock that uses SKTestSession
+ /*
+ let path = Bundle.module.path(forResource: "TestingConfiguration", ofType: "storekit")!
+ let session = try SKTestSession(contentsOf: URL(fileURLWithPath: path, isDirectory: false))
+ session.disableDialogs = true
+ session.clearTransactions()
+ */
+ }
+}
diff --git a/Tests/SubscriptionTests/Managers/SubscriptionManagerTests.swift b/Tests/SubscriptionTests/Managers/SubscriptionManagerTests.swift
new file mode 100644
index 000000000..da15986cb
--- /dev/null
+++ b/Tests/SubscriptionTests/Managers/SubscriptionManagerTests.swift
@@ -0,0 +1,43 @@
+//
+// SubscriptionManagerTests.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 SubscriptionManagerTests: XCTestCase {
+
+ override func setUpWithError() throws {
+ // Put setup code here. This method is called before the invocation of each test method in the class.
+ }
+
+ override func tearDownWithError() throws {
+ // Put teardown code here. This method is called after the invocation of each test method in the class.
+ }
+
+ func testExample() throws {
+
+ let subscriptionManager = DefaultSubscriptionManager(storePurchaseManager: SubscriptionMockFactory.storePurchaseManager,
+ accountManager: SubscriptionMockFactory.accountManager,
+ subscriptionEndpointService: SubscriptionMockFactory.subscriptionEndpointService,
+ authEndpointService: SubscriptionMockFactory.authEndpointService,
+ subscriptionEnvironment: SubscriptionMockFactory.currentEnvironment)
+ let url = subscriptionManager.url(for: .activateSuccess)
+ XCTAssertEqual(url.absoluteString, "https://duckduckgo.com/subscriptions/activate/success?environment=staging")
+ }
+}
diff --git a/Tests/SubscriptionTests/Resources/StoreKitTestCertificate.cer b/Tests/SubscriptionTests/Resources/StoreKitTestCertificate.cer
new file mode 100644
index 000000000..58961565e
Binary files /dev/null and b/Tests/SubscriptionTests/Resources/StoreKitTestCertificate.cer differ
diff --git a/Tests/SubscriptionTests/Resources/TestingConfiguration.storekit b/Tests/SubscriptionTests/Resources/TestingConfiguration.storekit
new file mode 100644
index 000000000..c78c38581
--- /dev/null
+++ b/Tests/SubscriptionTests/Resources/TestingConfiguration.storekit
@@ -0,0 +1,132 @@
+{
+ "identifier" : "A2F4D5EE",
+ "nonRenewingSubscriptions" : [
+
+ ],
+ "products" : [
+
+ ],
+ "settings" : {
+ "_compatibilityTimeRate" : {
+ "3" : 6,
+ "5" : 1004
+ },
+ "_disableDialogs" : true,
+ "_failTransactionsEnabled" : false,
+ "_locale" : "en_US",
+ "_storefront" : "USA",
+ "_storeKitErrors" : [
+ {
+ "current" : null,
+ "enabled" : false,
+ "name" : "Load Products"
+ },
+ {
+ "current" : null,
+ "enabled" : false,
+ "name" : "Purchase"
+ },
+ {
+ "current" : null,
+ "enabled" : false,
+ "name" : "Verification"
+ },
+ {
+ "current" : null,
+ "enabled" : false,
+ "name" : "App Store Sync"
+ },
+ {
+ "current" : null,
+ "enabled" : false,
+ "name" : "Subscription Status"
+ },
+ {
+ "current" : null,
+ "enabled" : false,
+ "name" : "App Transaction"
+ },
+ {
+ "current" : null,
+ "enabled" : false,
+ "name" : "Manage Subscriptions Sheet"
+ },
+ {
+ "current" : null,
+ "enabled" : false,
+ "name" : "Refund Request Sheet"
+ },
+ {
+ "current" : null,
+ "enabled" : false,
+ "name" : "Offer Code Redeem Sheet"
+ }
+ ],
+ "_timeRate" : 1005
+ },
+ "subscriptionGroups" : [
+ {
+ "id" : "21416043",
+ "localizations" : [
+
+ ],
+ "name" : "Premium Privacy Protection Subscription",
+ "subscriptions" : [
+ {
+ "adHocOffers" : [
+
+ ],
+ "codeOffers" : [
+
+ ],
+ "displayPrice" : "9.99",
+ "familyShareable" : false,
+ "groupNumber" : 2,
+ "internalID" : "6473453075",
+ "introductoryOffer" : null,
+ "localizations" : [
+ {
+ "description" : "Monthly Subscription",
+ "displayName" : "Monthly Subscription",
+ "locale" : "en_US"
+ }
+ ],
+ "productID" : "ios.subscription.1month",
+ "recurringSubscriptionPeriod" : "P1M",
+ "referenceName" : "Monthly Subscription",
+ "subscriptionGroupID" : "21416043",
+ "type" : "RecurringSubscription"
+ },
+ {
+ "adHocOffers" : [
+
+ ],
+ "codeOffers" : [
+
+ ],
+ "displayPrice" : "99.99",
+ "familyShareable" : false,
+ "groupNumber" : 1,
+ "internalID" : "6473453289",
+ "introductoryOffer" : null,
+ "localizations" : [
+ {
+ "description" : "Yearly Subscription",
+ "displayName" : "Yearly Subscription",
+ "locale" : "en_US"
+ }
+ ],
+ "productID" : "ios.subscription.1year",
+ "recurringSubscriptionPeriod" : "P1Y",
+ "referenceName" : "Yearly Subscription",
+ "subscriptionGroupID" : "21416043",
+ "type" : "RecurringSubscription"
+ }
+ ]
+ }
+ ],
+ "version" : {
+ "major" : 3,
+ "minor" : 0
+ }
+}
diff --git a/Tests/SubscriptionTests/SubscriptionURLTests.swift b/Tests/SubscriptionTests/SubscriptionURLTests.swift
new file mode 100644
index 000000000..2b613bd5e
--- /dev/null
+++ b/Tests/SubscriptionTests/SubscriptionURLTests.swift
@@ -0,0 +1,38 @@
+//
+// SubscriptionURLTests.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 SubscriptionURLTests: XCTestCase {
+
+ override func setUpWithError() throws {
+ // Put setup code here. This method is called before the invocation of each test method in the class.
+ }
+
+ override func tearDownWithError() throws {
+ // Put teardown code here. This method is called after the invocation of each test method in the class.
+ }
+
+ func testURLs() throws {
+ let url = SubscriptionURL.activateSuccess.subscriptionURL(environment: SubscriptionEnvironment.ServiceEnvironment.staging)
+ XCTAssertEqual(url.absoluteString, "https://duckduckgo.com/subscriptions/activate/success?environment=staging")
+ // test all other URLs
+ }
+}