From 683424378092d5ca3c57df50d5676cf8c7d2d83c Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Thu, 4 Jul 2024 20:46:07 +1200 Subject: [PATCH 1/7] Update the Privacy Pro status attribute to use an array. (#878) Required: Task/Issue URL: https://app.asana.com/0/1199333091098016/1207731341550989/f iOS PR: duckduckgo/iOS#3033 macOS PR: duckduckgo/macos-browser#2940 What kind of version bump will this require?: Patch (technically the public API has not changed) Description: This PR updates the Privacy Pro status attribute to match an array instead of a string. --- .../Matchers/UserAttributeMatcher.swift | 18 ++++++------ .../Model/MatchingAttributes.swift | 11 ++++---- .../JsonToRemoteConfigModelMapperTests.swift | 2 +- .../Matchers/UserAttributeMatcherTests.swift | 28 ++++++++++++++----- .../Resources/remote-messaging-config.json | 2 +- 5 files changed, 38 insertions(+), 23 deletions(-) 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/Tests/BrowserServicesKitTests/RemoteMessaging/Mappers/JsonToRemoteConfigModelMapperTests.swift b/Tests/BrowserServicesKitTests/RemoteMessaging/Mappers/JsonToRemoteConfigModelMapperTests.swift index 0c2255a7e..82d88de45 100644 --- a/Tests/BrowserServicesKitTests/RemoteMessaging/Mappers/JsonToRemoteConfigModelMapperTests.swift +++ b/Tests/BrowserServicesKitTests/RemoteMessaging/Mappers/JsonToRemoteConfigModelMapperTests.swift @@ -168,7 +168,7 @@ class JsonToRemoteConfigModelMapperTests: XCTestCase { attribs = rule8?.attributes.filter { $0 is PrivacyProSubscriptionStatusMatchingAttribute } XCTAssertEqual(attribs?.first as? PrivacyProSubscriptionStatusMatchingAttribute, PrivacyProSubscriptionStatusMatchingAttribute( - value: "active", fallback: nil + value: ["active", "expiring"], fallback: nil )) let rule9 = config.rules.filter { $0.id == 9 }.first diff --git a/Tests/BrowserServicesKitTests/RemoteMessaging/Matchers/UserAttributeMatcherTests.swift b/Tests/BrowserServicesKitTests/RemoteMessaging/Matchers/UserAttributeMatcherTests.swift index 690f3773e..98fca95ac 100644 --- a/Tests/BrowserServicesKitTests/RemoteMessaging/Matchers/UserAttributeMatcherTests.swift +++ b/Tests/BrowserServicesKitTests/RemoteMessaging/Matchers/UserAttributeMatcherTests.swift @@ -250,35 +250,47 @@ class UserAttributeMatcherTests: XCTestCase { func testWhenPrivacyProSubscriptionStatusMatchesThenReturnMatch() throws { XCTAssertEqual(userAttributeMatcher.evaluate( - matchingAttribute: PrivacyProSubscriptionStatusMatchingAttribute(value: "active", fallback: nil) + matchingAttribute: PrivacyProSubscriptionStatusMatchingAttribute(value: ["active"], fallback: nil) + ), .match) + } + + func testWhenPrivacyProSubscriptionStatusHasMultipleAttributesAndOneMatchesThenReturnMatch() throws { + XCTAssertEqual(userAttributeMatcher.evaluate( + matchingAttribute: PrivacyProSubscriptionStatusMatchingAttribute(value: ["active", "expiring", "expired"], fallback: nil) ), .match) } func testWhenPrivacyProSubscriptionStatusDoesNotMatchThenReturnFail() throws { XCTAssertEqual(userAttributeMatcher.evaluate( - matchingAttribute: PrivacyProSubscriptionStatusMatchingAttribute(value: "expiring", fallback: nil) + matchingAttribute: PrivacyProSubscriptionStatusMatchingAttribute(value: ["expiring"], fallback: nil) ), .fail) } func testWhenPrivacyProSubscriptionStatusHasUnsupportedStatusThenReturnFail() throws { XCTAssertEqual(userAttributeMatcher.evaluate( - matchingAttribute: PrivacyProSubscriptionStatusMatchingAttribute(value: "unsupported_status", fallback: nil) + matchingAttribute: PrivacyProSubscriptionStatusMatchingAttribute(value: ["unsupported_status"], fallback: nil) ), .fail) } func testWhenOneDismissedMessageIdMatchesThenReturnMatch() throws { setUpUserAttributeMatcher(dismissedMessageIds: ["1"]) - XCTAssertEqual(userAttributeMatcher.evaluate(matchingAttribute: InteractedWithMessageMatchingAttribute(value: ["1", "2", "3"], fallback: nil)), .match) + XCTAssertEqual(userAttributeMatcher.evaluate( + matchingAttribute: InteractedWithMessageMatchingAttribute(value: ["1", "2", "3"], fallback: nil) + ), .match) } func testWhenAllDismissedMessageIdsMatchThenReturnMatch() throws { setUpUserAttributeMatcher(dismissedMessageIds: ["1", "2", "3"]) - XCTAssertEqual(userAttributeMatcher.evaluate(matchingAttribute: InteractedWithMessageMatchingAttribute(value: ["1", "2", "3"], fallback: nil)), .match) + XCTAssertEqual(userAttributeMatcher.evaluate( + matchingAttribute: InteractedWithMessageMatchingAttribute(value: ["1", "2", "3"], fallback: nil) + ), .match) } func testWhenNoDismissedMessageIdsMatchThenReturnFail() throws { setUpUserAttributeMatcher(dismissedMessageIds: ["1", "2", "3"]) - XCTAssertEqual(userAttributeMatcher.evaluate(matchingAttribute: InteractedWithMessageMatchingAttribute(value: ["4", "5"], fallback: nil)), .fail) + XCTAssertEqual(userAttributeMatcher.evaluate( + matchingAttribute: InteractedWithMessageMatchingAttribute(value: ["4", "5"], fallback: nil) + ), .fail) } func testWhenHaveDismissedMessageIdsAndMatchAttributeIsEmptyThenReturnFail() throws { @@ -288,7 +300,9 @@ class UserAttributeMatcherTests: XCTestCase { func testWhenHaveNoDismissedMessageIdsAndMatchAttributeIsNotEmptyThenReturnFail() throws { setUpUserAttributeMatcher(dismissedMessageIds: []) - XCTAssertEqual(userAttributeMatcher.evaluate(matchingAttribute: InteractedWithMessageMatchingAttribute(value: ["1", "2"], fallback: nil)), .fail) + XCTAssertEqual(userAttributeMatcher.evaluate( + matchingAttribute: InteractedWithMessageMatchingAttribute(value: ["1", "2"], fallback: nil) + ), .fail) } private func setUpUserAttributeMatcher(dismissedMessageIds: [String] = []) { diff --git a/Tests/BrowserServicesKitTests/Resources/remote-messaging-config.json b/Tests/BrowserServicesKitTests/Resources/remote-messaging-config.json index fb58b7259..8359a757d 100644 --- a/Tests/BrowserServicesKitTests/Resources/remote-messaging-config.json +++ b/Tests/BrowserServicesKitTests/Resources/remote-messaging-config.json @@ -270,7 +270,7 @@ "value": ["apple", "stripe"] }, "pproSubscriptionStatus": { - "value": "active" + "value": ["active", "expiring"] }, "pproDaysSinceSubscribed": { "min": 5, From 28dd48c5aca37c46402e2a14f7c47aad3877b3aa Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Thu, 4 Jul 2024 15:02:19 +0600 Subject: [PATCH 2/7] Fixes for Xcode16 (#864) Task/Issue URL: https://app.asana.com/0/1199230911884351/1207655348648728/f macOS PR: duckduckgo/macos-browser#2907 --- .../FaviconsFetchOperation.swift | 2 +- .../SmarterEncryption/HTTPSUpgrade.swift | 6 +-- .../Concurrency/MainActorExtension.swift | 45 +++++++++++++++++++ Sources/Common/Extensions/URLExtension.swift | 2 +- Sources/DDGSync/internal/SyncOperation.swift | 2 +- Sources/Navigation/Navigation.swift | 11 ++++- Sources/Navigation/NavigationAction.swift | 6 +-- Sources/Navigation/Navigator.swift | 11 ++++- .../NetworkProtectionConnectionTester.swift | 2 +- .../Recovery/FailureRecoveryHandler.swift | 1 + .../Recovery/Reasserting.swift | 2 - .../PrivacyDashboard/Model/TrackerInfo.swift | 2 +- .../PrivacyDashboardUserScript.swift | 1 + .../Managers/SubscriptionManager.swift | 2 +- Sources/WireGuardC/include/WireGuardC.h | 1 + .../HistoryCoordinatorTests.swift | 1 - 16 files changed, 78 insertions(+), 19 deletions(-) create mode 100644 Sources/Common/Concurrency/MainActorExtension.swift 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/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/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/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/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/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/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/NetworkProtectionConnectionTester.swift b/Sources/NetworkProtection/Diagnostics/NetworkProtectionConnectionTester.swift index be98ebf95..76f35914c 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 diff --git a/Sources/NetworkProtection/Recovery/FailureRecoveryHandler.swift b/Sources/NetworkProtection/Recovery/FailureRecoveryHandler.swift index f638d8767..e780e999c 100644 --- a/Sources/NetworkProtection/Recovery/FailureRecoveryHandler.swift +++ b/Sources/NetworkProtection/Recovery/FailureRecoveryHandler.swift @@ -94,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) 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/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/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/Subscription/Managers/SubscriptionManager.swift b/Sources/Subscription/Managers/SubscriptionManager.swift index 1a7168d97..1087dab90 100644 --- a/Sources/Subscription/Managers/SubscriptionManager.swift +++ b/Sources/Subscription/Managers/SubscriptionManager.swift @@ -133,7 +133,7 @@ public final class DefaultSubscriptionManager: SubscriptionManager { case .success(let subscription): isSubscriptionActive = subscription.isActive case .failure(let error): - if case let .apiError(serviceError) = error, case let .serverError(statusCode, error) = serviceError { + if case let .apiError(serviceError) = error, case let .serverError(statusCode, _) = serviceError { if statusCode == 401 { // Token is no longer valid accountManager.signOut() 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/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! From 777e5ae1ab890d9ec22e069bc5dc0f0ada4b35af Mon Sep 17 00:00:00 2001 From: Federico Cappelli Date: Fri, 5 Jul 2024 10:49:01 +0100 Subject: [PATCH 3/7] Subscription refactoring #5 (#874) Task/Issue URL: https://app.asana.com/0/1205842942115003/1206805455884775/f iOS PR: https://github.com/duckduckgo/iOS/pull/3023 macOS PR: https://github.com/duckduckgo/macos-browser/pull/2930 What kind of version bump will this require?: Major Tech Design URL: https://app.asana.com/0/1205842942115003/1207147511614062/f Dependency injection for many Subscription classes has been improved to allow unit tests. Many test classes have been created and one or more example tests have been implemented to showcase the class testability and mocks Complete tests will be implemented in follow-up tasks --- .../xcshareddata/WorkspaceSettings.xcsettings | 8 + .../BrowserServicesKit-Package.xcscheme | 645 ++++++++++++++++++ .../xcschemes/SubscriptionTests.xcscheme | 64 ++ Sources/Common/Extensions/DateExtension.swift | 8 + .../Subscription/API/Model/Entitlement.swift | 1 - .../AppStoreAccountManagementFlow.swift | 17 +- .../Flows/AppStore/AppStorePurchaseFlow.swift | 32 +- .../Flows/AppStore/AppStoreRestoreFlow.swift | 26 +- .../Flows/Stripe/StripePurchaseFlow.swift | 26 +- .../Managers/StorePurchaseManager.swift | 5 +- .../Managers/SubscriptionManager.swift | 9 +- Sources/Subscription/SubscriptionURL.swift | 2 +- .../APIs/APIServiceMock.swift | 15 +- .../APIs/AuthEndpointServiceMock.swift | 10 +- .../SubscriptionEndpointServiceMock.swift | 20 +- .../AccountKeychainStorageMock.swift | 7 + .../SubscriptionManagerMock.swift | 10 + .../SubscriptionMockFactory.swift | 90 +++ ...SubscriptionTokenKeychainStorageMock.swift | 4 + .../API/AuthEndpointServiceTests.swift | 71 ++ .../API/Models/EntitlementTests.swift | 41 ++ .../API/Models/SubscriptionTests.swift | 62 ++ .../SubscriptionEndpointServiceTests.swift | 61 ++ .../AppStoreAccountManagementFlowTests.swift | 48 ++ .../Flows/AppStorePurchaseFlowTests.swift | 55 ++ .../Flows/AppStoreRestoreFlowTests.swift | 50 ++ .../Models/PurchaseUpdateTests.swift} | 15 +- .../Models/SubscriptionOptionsTests.swift | 36 + .../Flows/StripePurchaseFlowTests.swift | 58 ++ .../Managers/AccountManagerTests.swift | 55 ++ .../Managers/StorePurchaseManagerTests.swift | 52 ++ .../Managers/SubscriptionManagerTests.swift | 43 ++ .../Resources/StoreKitTestCertificate.cer | Bin 0 -> 888 bytes .../Resources/TestingConfiguration.storekit | 132 ++++ .../SubscriptionURLTests.swift | 38 ++ 35 files changed, 1755 insertions(+), 61 deletions(-) create mode 100644 .swiftpm/xcode/package.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings create mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/BrowserServicesKit-Package.xcscheme create mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/SubscriptionTests.xcscheme create mode 100644 Sources/SubscriptionTestingUtilities/SubscriptionMockFactory.swift create mode 100644 Tests/SubscriptionTests/API/AuthEndpointServiceTests.swift create mode 100644 Tests/SubscriptionTests/API/Models/EntitlementTests.swift create mode 100644 Tests/SubscriptionTests/API/Models/SubscriptionTests.swift create mode 100644 Tests/SubscriptionTests/API/SubscriptionEndpointServiceTests.swift create mode 100644 Tests/SubscriptionTests/Flows/AppStoreAccountManagementFlowTests.swift create mode 100644 Tests/SubscriptionTests/Flows/AppStorePurchaseFlowTests.swift create mode 100644 Tests/SubscriptionTests/Flows/AppStoreRestoreFlowTests.swift rename Tests/SubscriptionTests/{SubscriptionTests.swift => Flows/Models/PurchaseUpdateTests.swift} (61%) create mode 100644 Tests/SubscriptionTests/Flows/Models/SubscriptionOptionsTests.swift create mode 100644 Tests/SubscriptionTests/Flows/StripePurchaseFlowTests.swift create mode 100644 Tests/SubscriptionTests/Managers/AccountManagerTests.swift create mode 100644 Tests/SubscriptionTests/Managers/StorePurchaseManagerTests.swift create mode 100644 Tests/SubscriptionTests/Managers/SubscriptionManagerTests.swift create mode 100644 Tests/SubscriptionTests/Resources/StoreKitTestCertificate.cer create mode 100644 Tests/SubscriptionTests/Resources/TestingConfiguration.storekit create mode 100644 Tests/SubscriptionTests/SubscriptionURLTests.swift 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/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/Subscription/API/Model/Entitlement.swift b/Sources/Subscription/API/Model/Entitlement.swift index ece4b618a..c90e7342c 100644 --- a/Sources/Subscription/API/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/Flows/AppStore/AppStoreAccountManagementFlow.swift b/Sources/Subscription/Flows/AppStore/AppStoreAccountManagementFlow.swift index 27def2247..acbf4ab82 100644 --- a/Sources/Subscription/Flows/AppStore/AppStoreAccountManagementFlow.swift +++ b/Sources/Subscription/Flows/AppStore/AppStoreAccountManagementFlow.swift @@ -33,11 +33,14 @@ public protocol AppStoreAccountManagementFlow { @available(macOS 12.0, iOS 15.0, *) public final class DefaultAppStoreAccountManagementFlow: AppStoreAccountManagementFlow { - private let subscriptionManager: SubscriptionManager - private var accountManager: AccountManager { subscriptionManager.accountManager } + private let authEndpointService: AuthEndpointService + private let storePurchaseManager: StorePurchaseManager + private let accountManager: AccountManager - public init(subscriptionManager: SubscriptionManager) { - self.subscriptionManager = subscriptionManager + public init(authEndpointService: any AuthEndpointService, storePurchaseManager: any StorePurchaseManager, accountManager: any AccountManager) { + self.authEndpointService = authEndpointService + self.storePurchaseManager = storePurchaseManager + self.accountManager = accountManager } @discardableResult @@ -46,13 +49,13 @@ public final class DefaultAppStoreAccountManagementFlow: AppStoreAccountManageme var authToken = accountManager.authToken ?? "" // Check if auth token if still valid - if case let .failure(validateTokenError) = await subscriptionManager.authEndpointService.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.authEndpointService.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 c05c8feb8..ff35bbcd0 100644 --- a/Sources/Subscription/Flows/AppStore/AppStorePurchaseFlow.swift +++ b/Sources/Subscription/Flows/AppStore/AppStorePurchaseFlow.swift @@ -41,14 +41,22 @@ public protocol AppStorePurchaseFlow { @available(macOS 12.0, iOS 15.0, *) public final class DefaultAppStorePurchaseFlow: AppStorePurchaseFlow { - - private let subscriptionManager: SubscriptionManager - private var accountManager: AccountManager { subscriptionManager.accountManager } + private let subscriptionEndpointService: SubscriptionEndpointService + private let storePurchaseManager: StorePurchaseManager + private let accountManager: AccountManager private let appStoreRestoreFlow: AppStoreRestoreFlow - - public init(subscriptionManager: SubscriptionManager, appStoreRestoreFlow: AppStoreRestoreFlow) { - self.subscriptionManager = subscriptionManager + 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 } // swiftlint:disable cyclomatic_complexity @@ -75,7 +83,7 @@ public final class DefaultAppStorePurchaseFlow: AppStorePurchaseFlow { accountManager.storeAuthToken(token: expiredAccountDetails.authToken) accountManager.storeAccount(token: expiredAccountDetails.accessToken, email: expiredAccountDetails.email, externalID: expiredAccountDetails.externalID) default: - switch await subscriptionManager.authEndpointService.createAccount(emailAccessToken: emailAccessToken) { + switch await authEndpointService.createAccount(emailAccessToken: emailAccessToken) { case .success(let response): externalID = response.externalID @@ -93,7 +101,7 @@ public final class DefaultAppStorePurchaseFlow: 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): @@ -113,16 +121,16 @@ public final class DefaultAppStorePurchaseFlow: AppStorePurchaseFlow { public func completeSubscriptionPurchase(with transactionJWS: TransactionJWS) async -> Result { // Clear subscription Cache - subscriptionManager.subscriptionEndpointService.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.subscriptionEndpointService.confirmPurchase(accessToken: accessToken, signature: transactionJWS) { + switch await subscriptionEndpointService.confirmPurchase(accessToken: accessToken, signature: transactionJWS) { case .success(let confirmation): - subscriptionManager.subscriptionEndpointService.updateCache(with: confirmation.subscription) + subscriptionEndpointService.updateCache(with: confirmation.subscription) accountManager.updateCache(with: confirmation.entitlements) return true case .failure: @@ -157,7 +165,7 @@ public final class DefaultAppStorePurchaseFlow: AppStorePurchaseFlow { let token = accountManager.accessToken else { return nil } - let subscriptionInfo = await subscriptionManager.subscriptionEndpointService.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 bf1328ca5..15a8b5c94 100644 --- a/Sources/Subscription/Flows/AppStore/AppStoreRestoreFlow.swift +++ b/Sources/Subscription/Flows/AppStore/AppStoreRestoreFlow.swift @@ -38,22 +38,30 @@ public protocol AppStoreRestoreFlow { @available(macOS 12.0, iOS 15.0, *) public final class DefaultAppStoreRestoreFlow: AppStoreRestoreFlow { - private let subscriptionManager: SubscriptionManager - var accountManager: AccountManager { subscriptionManager.accountManager } - - public init(subscriptionManager: SubscriptionManager) { - self.subscriptionManager = subscriptionManager + 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 { // Clear subscription Cache - subscriptionManager.subscriptionEndpointService.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) } @@ -61,7 +69,7 @@ public final class DefaultAppStoreRestoreFlow: AppStoreRestoreFlow { // Do the store login to get short-lived token let authToken: String - switch await subscriptionManager.authEndpointService.storeLogin(signature: lastTransactionJWSRepresentation) { + switch await authEndpointService.storeLogin(signature: lastTransactionJWSRepresentation) { case .success(let response): authToken = response.authToken case .failure: @@ -92,7 +100,7 @@ public final class DefaultAppStoreRestoreFlow: AppStoreRestoreFlow { var isSubscriptionActive = false - switch await subscriptionManager.subscriptionEndpointService.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 86b70961f..2e561882a 100644 --- a/Sources/Subscription/Flows/Stripe/StripePurchaseFlow.swift +++ b/Sources/Subscription/Flows/Stripe/StripePurchaseFlow.swift @@ -32,18 +32,22 @@ public protocol StripePurchaseFlow { } public final class DefaultStripePurchaseFlow: StripePurchaseFlow { - - private let subscriptionManager: SubscriptionManager - var accountManager: AccountManager { subscriptionManager.accountManager } - - public init(subscriptionManager: SubscriptionManager) { - self.subscriptionManager = subscriptionManager + 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 { os_log(.info, log: .subscription, "[StripePurchaseFlow] subscriptionOptions") - guard case let .success(products) = await subscriptionManager.subscriptionEndpointService.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) } @@ -78,7 +82,7 @@ public final class DefaultStripePurchaseFlow: StripePurchaseFlow { os_log(.info, log: .subscription, "[StripePurchaseFlow] prepareSubscriptionPurchase") // Clear subscription Cache - subscriptionManager.subscriptionEndpointService.signOut() + subscriptionEndpointService.signOut() var token: String = "" if let accessToken = accountManager.accessToken { @@ -86,7 +90,7 @@ public final class DefaultStripePurchaseFlow: StripePurchaseFlow { token = accessToken } } else { - switch await subscriptionManager.authEndpointService.createAccount(emailAccessToken: emailAccessToken) { + switch await authEndpointService.createAccount(emailAccessToken: emailAccessToken) { case .success(let response): token = response.authToken accountManager.storeAuthToken(token: token) @@ -100,7 +104,7 @@ public final class DefaultStripePurchaseFlow: StripePurchaseFlow { } private func isSubscriptionExpired(accessToken: String) async -> Bool { - if case .success(let subscription) = await subscriptionManager.subscriptionEndpointService.getSubscription(accessToken: accessToken) { + if case .success(let subscription) = await subscriptionEndpointService.getSubscription(accessToken: accessToken) { return !subscription.isActive } @@ -109,7 +113,7 @@ public final class DefaultStripePurchaseFlow: StripePurchaseFlow { public func completeSubscriptionPurchase() async { // Clear subscription Cache - subscriptionManager.subscriptionEndpointService.signOut() + subscriptionEndpointService.signOut() os_log(.info, log: .subscription, "[StripePurchaseFlow] completeSubscriptionPurchase") if !accountManager.isUserAuthenticated, diff --git a/Sources/Subscription/Managers/StorePurchaseManager.swift b/Sources/Subscription/Managers/StorePurchaseManager.swift index 5479bfdd5..25f550d8c 100644 --- a/Sources/Subscription/Managers/StorePurchaseManager.swift +++ b/Sources/Subscription/Managers/StorePurchaseManager.swift @@ -66,10 +66,7 @@ public final class DefaultStorePurchaseManager: ObservableObject, StorePurchaseM @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? diff --git a/Sources/Subscription/Managers/SubscriptionManager.swift b/Sources/Subscription/Managers/SubscriptionManager.swift index 1087dab90..c85b80224 100644 --- a/Sources/Subscription/Managers/SubscriptionManager.swift +++ b/Sources/Subscription/Managers/SubscriptionManager.swift @@ -20,11 +20,16 @@ import Foundation import Common 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() -> StorePurchaseManager func loadInitialData() @@ -34,9 +39,7 @@ public protocol SubscriptionManager { /// 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. public final class DefaultSubscriptionManager: SubscriptionManager { - private let _storePurchaseManager: StorePurchaseManager? - public let accountManager: AccountManager public let subscriptionEndpointService: SubscriptionEndpointService public let authEndpointService: AuthEndpointService 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 index ddbc50151..ab7916690 100644 --- a/Sources/SubscriptionTestingUtilities/APIs/APIServiceMock.swift +++ b/Sources/SubscriptionTestingUtilities/APIs/APIServiceMock.swift @@ -21,16 +21,23 @@ import Subscription public struct APIServiceMock: APIService { public var mockAuthHeaders: [String: String] - public var mockAPICallResults: Result + public var mockAPICallSuccessResult: Any? + public var mockAPICallError: APIServiceError? - public init(mockAuthHeaders: [String: String], mockAPICallResults: Result) { + public init(mockAuthHeaders: [String: String], mockAPICallSuccessResult: Any? = nil, mockAPICallError: APIServiceError? = nil) { self.mockAuthHeaders = mockAuthHeaders - self.mockAPICallResults = mockAPICallResults + 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 { - return mockAPICallResults as! Result + if let success = mockAPICallSuccessResult { + return .success(success as! T) + } else if let error = mockAPICallError { + return .failure(error) + } + return .failure(.unknownServerError) } // swiftlint:enable force_cast diff --git a/Sources/SubscriptionTestingUtilities/APIs/AuthEndpointServiceMock.swift b/Sources/SubscriptionTestingUtilities/APIs/AuthEndpointServiceMock.swift index 993bd60d7..3284b616b 100644 --- a/Sources/SubscriptionTestingUtilities/APIs/AuthEndpointServiceMock.swift +++ b/Sources/SubscriptionTestingUtilities/APIs/AuthEndpointServiceMock.swift @@ -25,7 +25,15 @@ public struct AuthEndpointServiceMock: AuthEndpointService { public var createAccountResult: Result? public var storeLoginResult: Result? - public init() {} + 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! diff --git a/Sources/SubscriptionTestingUtilities/APIs/SubscriptionEndpointServiceMock.swift b/Sources/SubscriptionTestingUtilities/APIs/SubscriptionEndpointServiceMock.swift index 13684f0dc..0890fe30d 100644 --- a/Sources/SubscriptionTestingUtilities/APIs/SubscriptionEndpointServiceMock.swift +++ b/Sources/SubscriptionTestingUtilities/APIs/SubscriptionEndpointServiceMock.swift @@ -20,12 +20,20 @@ import Foundation import Subscription public struct SubscriptionEndpointServiceMock: SubscriptionEndpointService { - var getSubscriptionResult: Result? - var getProductsResult: Result<[GetProductsItem], APIServiceError>? - var getCustomerPortalURLResult: Result? - var confirmPurchaseResult: Result? - - public init() {} + 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) { diff --git a/Sources/SubscriptionTestingUtilities/AccountKeychainStorageMock.swift b/Sources/SubscriptionTestingUtilities/AccountKeychainStorageMock.swift index 4e466434e..bf48ee36d 100644 --- a/Sources/SubscriptionTestingUtilities/AccountKeychainStorageMock.swift +++ b/Sources/SubscriptionTestingUtilities/AccountKeychainStorageMock.swift @@ -25,6 +25,13 @@ public class AccountKeychainStorageMock: AccountStoring { 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 } diff --git a/Sources/SubscriptionTestingUtilities/SubscriptionManagerMock.swift b/Sources/SubscriptionTestingUtilities/SubscriptionManagerMock.swift index ca5e8cf77..46fdc77e0 100644 --- a/Sources/SubscriptionTestingUtilities/SubscriptionManagerMock.swift +++ b/Sources/SubscriptionTestingUtilities/SubscriptionManagerMock.swift @@ -23,6 +23,16 @@ 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 currentEnvironment: SubscriptionEnvironment public var canPurchase: Bool 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 index 82ca5fa92..8e4e61912 100644 --- a/Sources/SubscriptionTestingUtilities/SubscriptionTokenKeychainStorageMock.swift +++ b/Sources/SubscriptionTestingUtilities/SubscriptionTokenKeychainStorageMock.swift @@ -22,6 +22,10 @@ import Subscription public class SubscriptionTokenKeychainStorageMock: SubscriptionTokenStoring { public var accessToken: String? + public init(accessToken: String? = nil) { + self.accessToken = accessToken + } + public func getAccessToken() throws -> String? { accessToken } 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 0000000000000000000000000000000000000000..58961565ea762ffaa908a55dec135cc88df88320 GIT binary patch literal 888 zcmXqLVlFXgVv1S7%*4pV#K>sC%f_kI=F#?@mywa1mBAq1P|$#%jX9KsnTI2|B)=%t zJF^5P#DyuujU>cvzzNdGCd?EXY$$FZ3OAUOQ4DUDft)z6k%57UfuW(fsgbd1lsK=6 zfq}7+Ih0F#2Q)D%A$yvUm4Ug5k)Oe!iIIz`iII`vcKrk!onrpm)788u@8A;LT$0p&v1o4de4hV6`WbJ=S=GG5WgK6RWa9S+Rt3It3z$Nvt^I}mMx24Z%|GWd6 z5+MX>4zw!3{)m!B!CbP)A=j^CRJ=gfDx9B;$RGym9 zccYEGyDcVUvmN=v`*do+*@-81seXw#+4HdQwwLQ?UCC+8VU5dN-j$X|2cNH46T8bU zK0rH0v*+%i>ssRXm9|`eQn6zFirnP~Z)NDFojI?`9ia30(C=sQLI;_c85tNCTN_w{ z(}AooBjbM-Rs&`rWxxlFUs-;T01Gn{Q;UHZh_4Fb^B8ckacHwKva+%>Gb0BtFiio2 zmyux;!vmX-FJ30?J#bCdVP5!7iwWgrL6=r|7v$|opZR-pSLporvKl+$wJsLVH+47P z?RL3L;M0f9H)oS(7XD1=YVoi*vbQ^3b?53FpD4Uxt@ zr_9*21f_dD&vE=uJK(P2{W|KPHfNa5(p8+y)kcPQUbI$toI7w-NHB|^XPrLlsr;&x zdIg=*XFeN+F8_TjAIDp}BjWmknkV(itEbP>KCE@)!ls8;qj)W)^V2m}3N>GAugIOb zH*Pc6gBj<(y*k{NoB4XXL!)iGZ9(A0BeA_p@6=~SNZm^)GY@+6e~n};kEnhZ|JTkF Q*R^Hk-e^x`U2f|S0Q7)HqW}N^ literal 0 HcmV?d00001 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 + } +} From f665aae8e752f970b99c5d053e08c8e0752df2f4 Mon Sep 17 00:00:00 2001 From: Diego Rey Mendez Date: Fri, 5 Jul 2024 12:12:26 +0200 Subject: [PATCH 4/7] Improve VPN logging logic (#877) Task/Issue URL: https://app.asana.com/0/1206580121312550/1207223772590802/f iOS PR: https://github.com/duckduckgo/iOS/pull/3032 macOS PR: https://github.com/duckduckgo/macos-browser/pull/2939 What kind of version bump will this require?: Major/Minor/Patch ## Description Improves the logging logic for the VPN. --- ...rotectionConnectionBandwidthAnalyzer.swift | 3 +- .../NetworkProtectionConnectionTester.swift | 2 +- ...etworkProtectionTunnelFailureMonitor.swift | 12 +- .../NetworkProtectionKeychainStore.swift | 2 +- .../NetworkProtection/Logging/Logging.swift | 6 +- .../PacketTunnelProvider.swift | 193 ++++++++++-------- .../Recovery/FailureRecoveryHandler.swift | 9 +- 7 files changed, 117 insertions(+), 110 deletions(-) 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..642086cca 100644 --- a/Sources/NetworkProtection/Diagnostics/NetworkProtectionConnectionTester.swift +++ b/Sources/NetworkProtection/Diagnostics/NetworkProtectionConnectionTester.swift @@ -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/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/PacketTunnelProvider.swift b/Sources/NetworkProtection/PacketTunnelProvider.swift index a97e80fbc..730c044e7 100644 --- a/Sources/NetworkProtection/PacketTunnelProvider.swift +++ b/Sources/NetworkProtection/PacketTunnelProvider.swift @@ -126,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) } } }() @@ -245,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 } @@ -636,14 +637,11 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { 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 } } @@ -672,22 +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("🔵 DNS server: %{public}@", log: .networkProtection, type: .info, String(describing: settings.dnsSettings)) + 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 } } @@ -697,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 @@ -719,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 { @@ -737,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() @@ -746,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))) @@ -794,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) @@ -824,20 +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)) @@ -845,55 +833,50 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { await stopMonitors() } - let tunnelConfiguration: TunnelConfiguration do { - tunnelConfiguration = try await generateTunnelConfiguration(serverSelectionMethod: serverSelectionMethod, - includedRoutes: includedRoutes ?? [], - excludedRoutes: settings.excludedRanges, - dnsSettings: settings.dnsSettings, - regenerateKey: regenerateKey) + 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 } } @@ -927,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 } @@ -949,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) @@ -1017,7 +1008,9 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { case .setExcludeLocalNetworks: Task { @MainActor in if case .connected = connectionStatus { - try? await updateTunnelConfiguration(reassert: false) + try? await updateTunnelConfiguration( + updateMethod: .selectServer(currentServerSelectionMethod), + reassert: false) } completionHandler?(nil) } @@ -1033,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) } @@ -1049,14 +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(reassert: true) + try? await updateTunnelConfiguration( + updateMethod: .selectServer(currentServerSelectionMethod), + reassert: true) } completionHandler?(nil) } @@ -1144,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) @@ -1158,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) } @@ -1209,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() @@ -1227,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) } @@ -1235,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 }) @@ -1297,7 +1302,7 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { connectionStatus = .connected(connectedDate: Date()) } - 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. @@ -1365,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 @@ -1425,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))) } } } @@ -1474,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 } } @@ -1546,14 +1557,14 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { @MainActor public override func sleep() async { - os_log("Sleep", log: .networkProtectionSleepLog, type: .info) + os_log("Sleep", log: .networkProtectionSleepLog) 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 @@ -1568,8 +1579,10 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { 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 f638d8767..88d13db41 100644 --- a/Sources/NetworkProtection/Recovery/FailureRecoveryHandler.swift +++ b/Sources/NetworkProtection/Recovery/FailureRecoveryHandler.swift @@ -144,14 +144,13 @@ actor FailureRecoveryHandler: FailureRecoveryHandling { 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), @@ -159,7 +158,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 } @@ -182,10 +181,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) From f23384018ede5aa63777b1c143e81855a16210fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacek=20=C5=81yp?= Date: Fri, 5 Jul 2024 17:37:38 +0200 Subject: [PATCH 5/7] Privacy Dashboard refactor (#879) Required: Task/Issue URL: https://app.asana.com/0/1201392122292466/1207677104594315/f iOS PR: duckduckgo/iOS#3038 macOS PR: duckduckgo/macos-browser#2943 What kind of version bump will this require?: Major Description: - remove old pixels, - remove user behavior toast experiment, - refactor Privacy Dashboard, - add documentation, - test Privacy Dashboard --- Package.resolved | 4 +- Package.swift | 2 +- .../ToggleProtectionsCounter.swift | 109 ----- .../AppPrivacyConfiguration.swift | 5 - .../PrivacyConfigurationManager.swift | 6 - .../BrokenSiteReport.swift | 17 +- .../Model/AllowedPermission.swift | 2 + .../Model/CookieConsentInfo.swift | 2 + .../Model/PermissionAuthorizationState.swift | 2 + .../Model/ProtectionStatus.swift | 1 + .../PrivacyDashboardController.swift | 446 ++++++------------ ...swift => PrivacyDashboardEntryPoint.swift} | 27 +- ...nts.swift => PrivacyDashboardEvents.swift} | 7 +- .../PrivacyDashboardURLBuilder.swift | 109 +++++ .../PrivacyDashboardUserScript.swift | 30 +- .../ToggleReportingConfiguration.swift | 37 ++ ...ure.swift => ToggleReportingFeature.swift} | 14 +- .../ToggleReportingFlow.swift | 103 ++++ ...ger.swift => ToggleReportingManager.swift} | 21 +- .../UserContentControllerTests.swift | 1 - .../ContentBlocker/WebViewTestHelper.swift | 3 +- .../GPC/GPCTests.swift | 3 +- .../PrivacyConfigurationReferenceTests.swift | 3 +- ...SubscriptionFeatureAvailabilityTests.swift | 1 - Tests/DDGSyncTests/Mocks/Mocks.swift | 1 - .../BrokenSiteReportMocks.swift | 18 +- .../Mocks/PrivacyDashboardDelegateMock.swift | 64 +++ .../Mocks/ToggleReportingManagerMock.swift | 21 +- .../PrivacyDashboardControllerTests.swift | 259 ++++++++++ ...wift => ToggleReportingManagerTests.swift} | 78 +-- 30 files changed, 823 insertions(+), 573 deletions(-) delete mode 100644 Sources/BrowserServicesKit/ContentBlocking/ToggleProtectionsCounter.swift rename Sources/PrivacyDashboard/{PrivacyDashboardMode.swift => PrivacyDashboardEntryPoint.swift} (55%) rename Sources/PrivacyDashboard/{ToggleReportEvents.swift => PrivacyDashboardEvents.swift} (83%) create mode 100644 Sources/PrivacyDashboard/PrivacyDashboardURLBuilder.swift create mode 100644 Sources/PrivacyDashboard/ToggleReportingConfiguration.swift rename Sources/PrivacyDashboard/{ToggleReportsFeature.swift => ToggleReportingFeature.swift} (84%) create mode 100644 Sources/PrivacyDashboard/ToggleReportingFlow.swift rename Sources/PrivacyDashboard/{ToggleReportsManager.swift => ToggleReportingManager.swift} (87%) create mode 100644 Tests/PrivacyDashboardTests/Mocks/PrivacyDashboardDelegateMock.swift rename Sources/BrowserServicesKit/ContentBlocking/ToggleProtectionsCounterStore.swift => Tests/PrivacyDashboardTests/Mocks/ToggleReportingManagerMock.swift (52%) create mode 100644 Tests/PrivacyDashboardTests/PrivacyDashboardControllerTests.swift rename Tests/PrivacyDashboardTests/{ToggleReportsManagerTests.swift => ToggleReportingManagerTests.swift} (69%) diff --git a/Package.resolved b/Package.resolved index a36194371..1232f1bcf 100644 --- a/Package.resolved +++ b/Package.resolved @@ -50,8 +50,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/duckduckgo/privacy-dashboard", "state" : { - "revision" : "924a80e20e2465dcaf3dca32c9b6e9b9968222b9", - "version" : "4.1.0" + "revision" : "348594efe2cd40ef156e915c272d02ec22f1903f", + "version" : "4.2.0" } }, { diff --git a/Package.swift b/Package.swift index 739111e9b..dd10f475e 100644 --- a/Package.swift +++ b/Package.swift @@ -45,7 +45,7 @@ let package = Package( .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.25.0"), - .package(url: "https://github.com/duckduckgo/privacy-dashboard", exact: "4.1.0"), + .package(url: "https://github.com/duckduckgo/privacy-dashboard", exact: "4.2.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"), .package(url: "https://github.com/duckduckgo/wireguard-apple", exact: "1.1.3"), diff --git a/Sources/BrowserServicesKit/ContentBlocking/ToggleProtectionsCounter.swift b/Sources/BrowserServicesKit/ContentBlocking/ToggleProtectionsCounter.swift deleted file mode 100644 index a3cea2863..000000000 --- a/Sources/BrowserServicesKit/ContentBlocking/ToggleProtectionsCounter.swift +++ /dev/null @@ -1,109 +0,0 @@ -// -// ToggleProtectionsCounter.swift -// -// Copyright © 2024 DuckDuckGo. All rights reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import Foundation -import Persistence -import Common - -public enum ToggleProtectionsCounterEvent { - - enum Parameter { - - static let onCountKey = "onCount" - static let offCountKey = "offCount" - - } - - case toggleProtectionsCounterDaily - -} - -/// This class aggregates protection toggles and stores that count over 24 hours. -public class ToggleProtectionsCounter { - - public enum Constant { - - public static let onCountKey = "ToggleProtectionsCounter_On_Count" - public static let offCountKey = "ToggleProtectionsCounter_Off_Count" - public static let lastSendAtKey = "ToggleProtectionsCounter_Date" - public static let sendInterval: Double = 60 * 60 * 24 // 24 hours - - } - - private let store: KeyValueStoring - private let sendInterval: TimeInterval - private let eventReporting: EventMapping? - - public init(store: KeyValueStoring = ToggleProtectionsCounterStore(), - sendInterval: TimeInterval = Constant.sendInterval, - eventReporting: EventMapping?) { - self.store = store - self.sendInterval = sendInterval - self.eventReporting = eventReporting - } - - public func onToggleOn(currentTime: Date = Date()) { - save(toggleOnCount: toggleOnCount + 1) - sendEventsIfNeeded(currentTime: currentTime) - } - - public func onToggleOff(currentTime: Date = Date()) { - save(toggleOffCount: toggleOffCount + 1) - sendEventsIfNeeded(currentTime: currentTime) - } - - public func sendEventsIfNeeded(currentTime: Date = Date()) { - guard let lastSendAt else { - save(lastSendAt: currentTime) - return - } - - if currentTime.timeIntervalSince(lastSendAt) > sendInterval { - eventReporting?.fire(.toggleProtectionsCounterDaily, parameters: [ - ToggleProtectionsCounterEvent.Parameter.onCountKey: String(toggleOnCount), - ToggleProtectionsCounterEvent.Parameter.offCountKey: String(toggleOffCount) - ]) - resetStats(currentTime: currentTime) - } - } - - private func resetStats(currentTime: Date = Date()) { - save(toggleOnCount: 0) - save(toggleOffCount: 0) - save(lastSendAt: currentTime) - } - - // MARK: - Store - - private var lastSendAt: Date? { store.object(forKey: Constant.lastSendAtKey) as? Date } - private var toggleOnCount: Int { store.object(forKey: Constant.onCountKey) as? Int ?? 0 } - private var toggleOffCount: Int { store.object(forKey: Constant.offCountKey) as? Int ?? 0 } - - private func save(lastSendAt: Date) { - store.set(lastSendAt, forKey: Constant.lastSendAtKey) - } - - private func save(toggleOnCount: Int) { - store.set(toggleOnCount, forKey: Constant.onCountKey) - } - - private func save(toggleOffCount: Int) { - store.set(toggleOffCount, forKey: Constant.offCountKey) - } - -} diff --git a/Sources/BrowserServicesKit/PrivacyConfig/AppPrivacyConfiguration.swift b/Sources/BrowserServicesKit/PrivacyConfig/AppPrivacyConfiguration.swift index 0ab0821d0..534ad6088 100644 --- a/Sources/BrowserServicesKit/PrivacyConfig/AppPrivacyConfiguration.swift +++ b/Sources/BrowserServicesKit/PrivacyConfig/AppPrivacyConfiguration.swift @@ -32,7 +32,6 @@ public struct AppPrivacyConfiguration: PrivacyConfiguration { private let data: PrivacyConfigurationData private let locallyUnprotected: DomainsProtectionStore private let internalUserDecider: InternalUserDecider - private let toggleProtectionsCounter: ToggleProtectionsCounter private let userDefaults: UserDefaults private let installDate: Date? @@ -41,14 +40,12 @@ public struct AppPrivacyConfiguration: PrivacyConfiguration { localProtection: DomainsProtectionStore, internalUserDecider: InternalUserDecider, userDefaults: UserDefaults = UserDefaults(), - toggleProtectionsCounter: ToggleProtectionsCounter, installDate: Date? = nil) { self.data = data self.identifier = identifier self.locallyUnprotected = localProtection self.internalUserDecider = internalUserDecider self.userDefaults = userDefaults - self.toggleProtectionsCounter = toggleProtectionsCounter self.installDate = installDate } @@ -291,12 +288,10 @@ public struct AppPrivacyConfiguration: PrivacyConfiguration { unprotectedDomain.punycodeEncodedHostname.lowercased() == domain } locallyUnprotected.enableProtection(forDomain: domainToRemove ?? domain) - toggleProtectionsCounter.onToggleOn() } public func userDisabledProtection(forDomain domain: String) { locallyUnprotected.disableProtection(forDomain: domain.punycodeEncodedHostname.lowercased()) - toggleProtectionsCounter.onToggleOff() } } diff --git a/Sources/BrowserServicesKit/PrivacyConfig/PrivacyConfigurationManager.swift b/Sources/BrowserServicesKit/PrivacyConfig/PrivacyConfigurationManager.swift index b582d575f..219a01613 100644 --- a/Sources/BrowserServicesKit/PrivacyConfig/PrivacyConfigurationManager.swift +++ b/Sources/BrowserServicesKit/PrivacyConfig/PrivacyConfigurationManager.swift @@ -32,7 +32,6 @@ public protocol PrivacyConfigurationManaging: AnyObject { var updatesPublisher: AnyPublisher { get } var privacyConfig: PrivacyConfiguration { get } var internalUserDecider: InternalUserDecider { get } - var toggleProtectionsCounter: ToggleProtectionsCounter { get } @discardableResult func reload(etag: String?, data: Data?) -> PrivacyConfigurationManager.ReloadResult } @@ -57,7 +56,6 @@ public class PrivacyConfigurationManager: PrivacyConfigurationManaging { private let errorReporting: EventMapping? private let installDate: Date? - public let toggleProtectionsCounter: ToggleProtectionsCounter public let internalUserDecider: InternalUserDecider private let updatesSubject = PassthroughSubject() @@ -111,7 +109,6 @@ public class PrivacyConfigurationManager: PrivacyConfigurationManaging { embeddedDataProvider: EmbeddedDataProvider, localProtection: DomainsProtectionStore, errorReporting: EventMapping? = nil, - toggleProtectionsCounterEventReporting: EventMapping? = nil, internalUserDecider: InternalUserDecider, installDate: Date? = nil ) { @@ -120,7 +117,6 @@ public class PrivacyConfigurationManager: PrivacyConfigurationManaging { self.errorReporting = errorReporting self.internalUserDecider = internalUserDecider self.installDate = installDate - self.toggleProtectionsCounter = ToggleProtectionsCounter(eventReporting: toggleProtectionsCounterEventReporting) reload(etag: fetchedETag, data: fetchedData) } @@ -131,7 +127,6 @@ public class PrivacyConfigurationManager: PrivacyConfigurationManaging { identifier: fetchedData.etag, localProtection: localProtection, internalUserDecider: internalUserDecider, - toggleProtectionsCounter: toggleProtectionsCounter, installDate: installDate) } @@ -139,7 +134,6 @@ public class PrivacyConfigurationManager: PrivacyConfigurationManaging { identifier: embeddedConfigData.etag, localProtection: localProtection, internalUserDecider: internalUserDecider, - toggleProtectionsCounter: toggleProtectionsCounter, installDate: installDate) } diff --git a/Sources/PrivacyDashboard/BrokenSiteReporting/BrokenSiteReport.swift b/Sources/PrivacyDashboard/BrokenSiteReporting/BrokenSiteReport.swift index 167592558..74276e230 100644 --- a/Sources/PrivacyDashboard/BrokenSiteReporting/BrokenSiteReport.swift +++ b/Sources/PrivacyDashboard/BrokenSiteReporting/BrokenSiteReport.swift @@ -95,8 +95,6 @@ public struct BrokenSiteReport { let vpnOn: Bool let jsPerformance: [Double]? let userRefreshCount: Int - let didOpenReportInfo: Bool - let toggleReportCounter: Int? #if os(iOS) let siteType: SiteType let atb: String @@ -125,9 +123,7 @@ public struct BrokenSiteReport { openerContext: OpenerContext?, vpnOn: Bool, jsPerformance: [Double]?, - userRefreshCount: Int, - didOpenReportInfo: Bool, - toggleReportCounter: Int? + userRefreshCount: Int ) { self.siteUrl = siteUrl self.category = category @@ -149,8 +145,6 @@ public struct BrokenSiteReport { self.vpnOn = vpnOn self.jsPerformance = jsPerformance self.userRefreshCount = userRefreshCount - self.didOpenReportInfo = didOpenReportInfo - self.toggleReportCounter = toggleReportCounter } #endif @@ -179,8 +173,6 @@ public struct BrokenSiteReport { vpnOn: Bool, jsPerformance: [Double]?, userRefreshCount: Int, - didOpenReportInfo: Bool, - toggleReportCounter: Int?, variant: String ) { self.siteUrl = siteUrl @@ -206,8 +198,6 @@ public struct BrokenSiteReport { self.vpnOn = vpnOn self.jsPerformance = jsPerformance self.userRefreshCount = userRefreshCount - self.didOpenReportInfo = didOpenReportInfo - self.toggleReportCounter = toggleReportCounter self.variant = variant } #endif @@ -237,11 +227,6 @@ public struct BrokenSiteReport { result["category"] = category result["description"] = description ?? "" result["protectionsState"] = protectionsState.description - } else { - result["didOpenReportInfo"] = didOpenReportInfo.description - if let toggleReportCounter { - result["toggleReportCounter"] = String(toggleReportCounter) - } } if let lastSentDay = lastSentDay { diff --git a/Sources/PrivacyDashboard/Model/AllowedPermission.swift b/Sources/PrivacyDashboard/Model/AllowedPermission.swift index c4d36e9ea..e407c92ee 100644 --- a/Sources/PrivacyDashboard/Model/AllowedPermission.swift +++ b/Sources/PrivacyDashboard/Model/AllowedPermission.swift @@ -19,6 +19,7 @@ import Foundation public struct AllowedPermission: Codable { + var key: String var icon: String var title: String @@ -42,4 +43,5 @@ public struct AllowedPermission: Codable { self.paused = paused self.options = options } + } diff --git a/Sources/PrivacyDashboard/Model/CookieConsentInfo.swift b/Sources/PrivacyDashboard/Model/CookieConsentInfo.swift index c86872932..7b22a439e 100644 --- a/Sources/PrivacyDashboard/Model/CookieConsentInfo.swift +++ b/Sources/PrivacyDashboard/Model/CookieConsentInfo.swift @@ -19,6 +19,7 @@ import Foundation public struct CookieConsentInfo: Encodable { + let consentManaged: Bool let cosmetic: Bool? let optoutFailed: Bool? @@ -31,4 +32,5 @@ public struct CookieConsentInfo: Encodable { self.optoutFailed = optoutFailed self.selftestFailed = selftestFailed } + } diff --git a/Sources/PrivacyDashboard/Model/PermissionAuthorizationState.swift b/Sources/PrivacyDashboard/Model/PermissionAuthorizationState.swift index 1ebb6cb1c..9cc06d83c 100644 --- a/Sources/PrivacyDashboard/Model/PermissionAuthorizationState.swift +++ b/Sources/PrivacyDashboard/Model/PermissionAuthorizationState.swift @@ -19,7 +19,9 @@ import Foundation public enum PermissionAuthorizationState: String, CaseIterable { + case ask case grant case deny + } diff --git a/Sources/PrivacyDashboard/Model/ProtectionStatus.swift b/Sources/PrivacyDashboard/Model/ProtectionStatus.swift index af69ba1a5..b3359cf80 100644 --- a/Sources/PrivacyDashboard/Model/ProtectionStatus.swift +++ b/Sources/PrivacyDashboard/Model/ProtectionStatus.swift @@ -31,4 +31,5 @@ public struct ProtectionStatus: Encodable { self.allowlisted = allowlisted self.denylisted = denylisted } + } diff --git a/Sources/PrivacyDashboard/PrivacyDashboardController.swift b/Sources/PrivacyDashboard/PrivacyDashboardController.swift index 9217de25c..ec87f126f 100644 --- a/Sources/PrivacyDashboard/PrivacyDashboardController.swift +++ b/Sources/PrivacyDashboard/PrivacyDashboardController.swift @@ -23,55 +23,13 @@ import PrivacyDashboardResources import BrowserServicesKit import Common -extension UserDefaults { - - var toggleReportCounter: Int { - get { - integer(forKey: PrivacyDashboardController.Constant.toggleReportsCounter) - } - set { - set(newValue, forKey: PrivacyDashboardController.Constant.toggleReportsCounter) - } - } - -} - public enum PrivacyDashboardOpenSettingsTarget: String { + case general case cookiePopupManagement = "cpm" -} - -/// Navigation delegate for the pages provided by the PrivacyDashboardController -public protocol PrivacyDashboardNavigationDelegate: AnyObject { - - func privacyDashboardControllerDidTapClose(_ privacyDashboardController: PrivacyDashboardController) - func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, didSetHeight height: Int) - -} - -/// `Report broken site` web page delegate -public protocol PrivacyDashboardReportBrokenSiteDelegate: AnyObject { - - func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, - didRequestSubmitBrokenSiteReportWithCategory category: String, - description: String) - func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, - reportBrokenSiteDidChangeProtectionSwitch protectionState: ProtectionState) - func privacyDashboardControllerDidRequestShowAlertForMissingDescription(_ privacyDashboardController: PrivacyDashboardController) - func privacyDashboardControllerDidRequestShowGeneralFeedback(_ privacyDashboardController: PrivacyDashboardController) } -public protocol PrivacyDashboardToggleReportDelegate: AnyObject { - - func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, - didRequestSubmitToggleReportWithSource source: BrokenSiteReport.Source, - didOpenReportInfo: Bool, - toggleReportCounter: Int?) - -} - -/// `Privacy Dashboard` web page delegate public protocol PrivacyDashboardControllerDelegate: AnyObject { func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, @@ -83,98 +41,58 @@ public protocol PrivacyDashboardControllerDelegate: AnyObject { didRequestOpenSettings target: PrivacyDashboardOpenSettingsTarget) func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, didSelectBreakageCategory category: String) + func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, + didRequestSubmitBrokenSiteReportWithCategory category: String, + description: String) + func privacyDashboardControllerDidRequestShowAlertForMissingDescription(_ privacyDashboardController: PrivacyDashboardController) + func privacyDashboardControllerDidRequestShowGeneralFeedback(_ privacyDashboardController: PrivacyDashboardController) + func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, + didRequestSubmitToggleReportWithSource source: BrokenSiteReport.Source) + func privacyDashboardControllerDidRequestClose(_ privacyDashboardController: PrivacyDashboardController) + func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, didSetHeight height: Int) -#if os(macOS) func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, - didSetPermission permissionName: String, to state: PermissionAuthorizationState) + didSetPermission permissionName: String, + to state: PermissionAuthorizationState) func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, - setPermission permissionName: String, paused: Bool) -#endif + setPermission permissionName: String, + paused: Bool) } @MainActor public final class PrivacyDashboardController: NSObject { - fileprivate enum Constant { - - static let screenKey = "screen" - static let breakageScreenKey = "breakageScreen" - static let openerKey = "opener" - static let categoryKey = "category" - - static let menuScreenKey = "menu" - static let dashboardScreenKey = "dashboard" - - static let toggleReportsCounter = "com.duckduckgo.toggle-reports-counter" - - } - - private enum ToggleReportDismissType { - - case send - case doNotSend - case dismiss - - var event: PrivacyDashboardEvents? { - switch self { - case .send: return nil - case .doNotSend: return .toggleReportDoNotSend - case .dismiss: return .toggleReportDismiss - } - } - - } - - private enum ToggleReportDismissSource { - - case userScript - case viewWillDisappear - - } - - public weak var privacyDashboardDelegate: PrivacyDashboardControllerDelegate? - public weak var privacyDashboardNavigationDelegate: PrivacyDashboardNavigationDelegate? - public weak var privacyDashboardReportBrokenSiteDelegate: PrivacyDashboardReportBrokenSiteDelegate? - public weak var privacyDashboardToggleReportDelegate: PrivacyDashboardToggleReportDelegate? + public weak var delegate: PrivacyDashboardControllerDelegate? @Published public var theme: PrivacyDashboardTheme? - public var preferredLocale: String? @Published public var allowedPermissions: [AllowedPermission] = [] - public private(set) weak var privacyInfo: PrivacyInfo? - public let initDashboardMode: PrivacyDashboardMode - - private weak var webView: WKWebView? - private let privacyDashboardScript: PrivacyDashboardUserScript - private var cancellables = Set() - - private var protectionStateToSubmitOnToggleReportDismiss: ProtectionState? - private var didSendToggleReport: Bool = false + public var preferredLocale: String? - private let privacyConfigurationManager: PrivacyConfigurationManaging + public private(set) weak var privacyInfo: PrivacyInfo? + private let entryPoint: PrivacyDashboardEntryPoint + private let variant: PrivacyDashboardVariant private let eventMapping: EventMapping - private let variant: PrivacyDashboardVariant + weak var webView: WKWebView? + private var cancellables = Set() - private var toggleReportCounter: Int? { userDefaults.toggleReportCounter > 20 ? nil : userDefaults.toggleReportCounter } - private var toggleReportsManager: ToggleReportsManager + private let script: PrivacyDashboardUserScript + private var toggleReportingManager: ToggleReportingManaging - private let userDefaults: UserDefaults - private var didOpenReportInfo: Bool = false + /// Manages the toggle reporting flow if currently active, otherwise nil. + var toggleReportingFlow: ToggleReportingFlow? public init(privacyInfo: PrivacyInfo?, - dashboardMode: PrivacyDashboardMode, + entryPoint: PrivacyDashboardEntryPoint, variant: PrivacyDashboardVariant, - privacyConfigurationManager: PrivacyConfigurationManaging, - eventMapping: EventMapping, - userDefaults: UserDefaults = UserDefaults.standard) { + toggleReportingManager: ToggleReportingManaging, + eventMapping: EventMapping) { self.privacyInfo = privacyInfo - self.initDashboardMode = dashboardMode + self.entryPoint = entryPoint self.variant = variant - self.privacyConfigurationManager = privacyConfigurationManager - privacyDashboardScript = PrivacyDashboardUserScript(privacyConfigurationManager: privacyConfigurationManager) self.eventMapping = eventMapping - self.userDefaults = userDefaults - self.toggleReportsManager = ToggleReportsManager(feature: ToggleReportsFeature(manager: privacyConfigurationManager)) + self.toggleReportingManager = toggleReportingManager + script = PrivacyDashboardUserScript() } public func setup(for webView: WKWebView) { @@ -182,7 +100,30 @@ public protocol PrivacyDashboardControllerDelegate: AnyObject { webView.navigationDelegate = self setupPrivacyDashboardUserScript() - loadPrivacyDashboardHTML() + loadStartScreen() + startToggleReportingFlowIfNeeded() + } + + private func startToggleReportingFlowIfNeeded() { + if case .toggleReport(let completionHandler) = entryPoint { + toggleReportingFlow = ToggleReportingFlow(entryPoint: .appMenuProtectionsOff(completionHandler: completionHandler), + toggleReportingManager: toggleReportingManager, + controller: self) + } + } + + private func setupPrivacyDashboardUserScript() { + guard let webView else { return } + script.delegate = self + webView.configuration.userContentController.addUserScript(script.makeWKUserScriptSync()) + script.messageNames.forEach { messageName in + webView.configuration.userContentController.add(script, name: messageName) + } + } + + private func loadStartScreen() { + let url = PrivacyDashboardURLBuilder(configuration: .startScreen(entryPoint: entryPoint, variant: variant)).build() + webView?.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent().deletingLastPathComponent()) } public func updatePrivacyInfo(_ privacyInfo: PrivacyInfo?) { @@ -193,60 +134,53 @@ public protocol PrivacyDashboardControllerDelegate: AnyObject { sendProtectionStatus() } - public func cleanUp() { + public func cleanup() { cancellables.removeAll() - - privacyDashboardScript.messageNames.forEach { messageName in + script.messageNames.forEach { messageName in webView?.configuration.userContentController.removeScriptMessageHandler(forName: messageName) } } public func didStartRulesCompilation() { - guard let webView = self.webView else { return } - privacyDashboardScript.setIsPendingUpdates(true, webView: webView) + guard let webView else { return } + script.setIsPendingUpdates(true, webView: webView) } public func didFinishRulesCompilation() { - guard let webView = self.webView else { return } - privacyDashboardScript.setIsPendingUpdates(false, webView: webView) + guard let webView else { return } + script.setIsPendingUpdates(false, webView: webView) } - private func setupPrivacyDashboardUserScript() { - guard let webView = self.webView else { return } - - privacyDashboardScript.delegate = self - - webView.configuration.userContentController.addUserScript(privacyDashboardScript.makeWKUserScriptSync()) - - privacyDashboardScript.messageNames.forEach { messageName in - webView.configuration.userContentController.add(privacyDashboardScript, name: messageName) - } + public func handleViewWillDisappear() { + toggleReportingFlow?.handleViewWillDisappear() } - private func loadPrivacyDashboardHTML() { - guard var url = Bundle.privacyDashboardURL else { return } - let screen = initDashboardMode.screen(for: variant).rawValue - url = url.appendingParameter(name: Constant.screenKey, value: screen) - if let breakageScreen = breakageScreen(for: variant)?.rawValue { - url = url.appendingParameter(name: Constant.breakageScreenKey, value: breakageScreen) - } - if case .afterTogglePrompt(let category, _) = initDashboardMode { - url = url.appendingParameter(name: Constant.categoryKey, value: category) + public var source: BrokenSiteReport.Source { + var source: BrokenSiteReport.Source + switch entryPoint { + case .report: source = .appMenu + case .dashboard: source = .dashboard + case .toggleReport: source = .onProtectionsOffMenu + case .afterTogglePrompt: source = .afterTogglePrompt } - if case .toggleReport = initDashboardMode { - url = url.appendingParameter(name: Constant.openerKey, value: Constant.menuScreenKey) - userDefaults.toggleReportCounter += 1 + if let toggleReportingSource = toggleReportingFlow?.entryPoint.source { + source = toggleReportingSource } - webView?.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent().deletingLastPathComponent()) + return source } - private func breakageScreen(for variant: PrivacyDashboardVariant) -> BreakageScreen? { - switch variant { - case .control: return nil - case .a: return .categorySelection - case .b: return .categoryTypeSelection - } + func didChangeProtectionState(to protectionState: ProtectionState, didSendReport: Bool) { + delegate?.privacyDashboardController(self, didChangeProtectionSwitch: protectionState, didSendReport: didSendReport) + } + + func didRequestSubmitToggleReport(with source: BrokenSiteReport.Source) { + delegate?.privacyDashboardController(self, didRequestSubmitToggleReportWithSource: source) } + + func didRequestClose() { + delegate?.privacyDashboardControllerDidRequestClose(self) + } + } // MARK: - WKNavigationDelegate @@ -277,8 +211,8 @@ extension PrivacyDashboardController: WKNavigationDelegate { .removeDuplicates() .receive(on: DispatchQueue.main) .sink(receiveValue: { [weak self] themeName in - guard let self = self, let webView = self.webView else { return } - self.privacyDashboardScript.setTheme(themeName, webView: webView) + guard let self, let webView else { return } + script.setTheme(themeName, webView: webView) }) .store(in: &cancellables) } @@ -288,8 +222,8 @@ extension PrivacyDashboardController: WKNavigationDelegate { .receive(on: DispatchQueue.main) .throttle(for: 0.25, scheduler: RunLoop.main, latest: true) .sink(receiveValue: { [weak self] trackerInfo in - guard let self = self, let url = self.privacyInfo?.url, let webView = self.webView else { return } - self.privacyDashboardScript.setTrackerInfo(url, trackerInfo: trackerInfo, webView: webView) + guard let self, let url = privacyInfo?.url, let webView else { return } + script.setTrackerInfo(url, trackerInfo: trackerInfo, webView: webView) }) .store(in: &cancellables) } @@ -298,9 +232,9 @@ extension PrivacyDashboardController: WKNavigationDelegate { privacyInfo?.$connectionUpgradedTo .receive(on: DispatchQueue.main) .sink(receiveValue: { [weak self] connectionUpgradedTo in - guard let self = self, let webView = self.webView else { return } + guard let self, let webView else { return } let upgradedHttps = connectionUpgradedTo != nil - self.privacyDashboardScript.setUpgradedHttps(upgradedHttps, webView: webView) + script.setUpgradedHttps(upgradedHttps, webView: webView) }) .store(in: &cancellables) } @@ -313,8 +247,8 @@ extension PrivacyDashboardController: WKNavigationDelegate { } .receive(on: DispatchQueue.main) .sink(receiveValue: { [weak self] serverTrustViewModel in - guard let self = self, let serverTrustViewModel = serverTrustViewModel, let webView = self.webView else { return } - self.privacyDashboardScript.setServerTrust(serverTrustViewModel, webView: webView) + guard let self, let serverTrustViewModel, let webView else { return } + script.setServerTrust(serverTrustViewModel, webView: webView) }) .store(in: &cancellables) } @@ -323,8 +257,8 @@ extension PrivacyDashboardController: WKNavigationDelegate { privacyInfo?.$cookieConsentManaged .receive(on: DispatchQueue.main) .sink(receiveValue: { [weak self] consentManaged in - guard let self = self, let webView = self.webView else { return } - self.privacyDashboardScript.setConsentManaged(consentManaged, webView: webView) + guard let self, let webView else { return } + script.setConsentManaged(consentManaged, webView: webView) }) .store(in: &cancellables) } @@ -333,30 +267,26 @@ extension PrivacyDashboardController: WKNavigationDelegate { $allowedPermissions .receive(on: DispatchQueue.main) .sink(receiveValue: { [weak self] allowedPermissions in - guard let self = self, let webView = self.webView else { return } - self.privacyDashboardScript.setPermissions(allowedPermissions: allowedPermissions, webView: webView) + guard let self, let webView else { return } + script.setPermissions(allowedPermissions: allowedPermissions, webView: webView) }) .store(in: &cancellables) } private func sendProtectionStatus() { - guard let webView = self.webView, - let protectionStatus = privacyInfo?.protectionStatus - else { return } - - privacyDashboardScript.setProtectionStatus(protectionStatus, webView: webView) + guard let webView, let protectionStatus = privacyInfo?.protectionStatus else { return } + script.setProtectionStatus(protectionStatus, webView: webView) } private func sendParentEntity() { - guard let webView = self.webView else { return } - privacyDashboardScript.setParentEntity(privacyInfo?.parentEntity, webView: webView) + guard let webView else { return } + script.setParentEntity(privacyInfo?.parentEntity, webView: webView) } private func sendCurrentLocale() { - guard let webView = self.webView else { return } - + guard let webView else { return } let locale = preferredLocale ?? "en" - privacyDashboardScript.setLocale(locale, webView: webView) + script.setLocale(locale, webView: webView) } } @@ -366,7 +296,7 @@ extension PrivacyDashboardController: PrivacyDashboardUserScriptDelegate { func userScript(_ userScript: PrivacyDashboardUserScript, didRequestOpenSettings target: String) { let settingsTarget = PrivacyDashboardOpenSettingsTarget(rawValue: target) ?? .general - privacyDashboardDelegate?.privacyDashboardController(self, didRequestOpenSettings: settingsTarget) + delegate?.privacyDashboardController(self, didRequestOpenSettings: settingsTarget) } func userScript(_ userScript: PrivacyDashboardUserScript, didChangeProtectionState protectionState: ProtectionState) { @@ -376,205 +306,99 @@ extension PrivacyDashboardController: PrivacyDashboardUserScriptDelegate { if shouldSegueToToggleReportScreen(with: protectionState) { segueToToggleReportScreen(with: protectionState) } else { - didChangeProtectionState(protectionState) - closeDashboard() + delegate?.privacyDashboardController(self, didChangeProtectionSwitch: protectionState, didSendReport: false) + delegate?.privacyDashboardControllerDidRequestClose(self) } } private func shouldSegueToToggleReportScreen(with protectionState: ProtectionState) -> Bool { - !protectionState.isProtected && protectionState.eventOrigin.screen == .primaryScreen && toggleReportsManager.shouldShowToggleReport - } - - private func didChangeProtectionState(_ protectionState: ProtectionState, didSendReport: Bool = false) { - switch protectionState.eventOrigin.screen { - case .primaryScreen: - privacyDashboardDelegate?.privacyDashboardController(self, didChangeProtectionSwitch: protectionState, didSendReport: didSendReport) - case .breakageForm, .choiceToggle: - privacyDashboardReportBrokenSiteDelegate?.privacyDashboardController(self, reportBrokenSiteDidChangeProtectionSwitch: protectionState) - case .toggleReport, .promptBreakageForm, .categorySelection, .categoryTypeSelection, .choiceBreakageForm: - assertionFailure("These screen don't have toggling capability") - } + !protectionState.isProtected && protectionState.eventOrigin.screen == .primaryScreen && toggleReportingManager.shouldShowToggleReport } - private func segueToToggleReportScreen(with protectionStateToSubmit: ProtectionState) { - guard var url = Bundle.privacyDashboardURL else { return } - url = url.appendingParameter(name: Constant.screenKey, value: Screen.toggleReport.rawValue) - if case .dashboard = initDashboardMode { - url = url.appendingParameter(name: Constant.openerKey, value: Constant.dashboardScreenKey) - } - + private func segueToToggleReportScreen(with protectionState: ProtectionState) { + let url = PrivacyDashboardURLBuilder(configuration: .segueToScreen(.toggleReport, entryPoint: entryPoint)).build() webView?.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent().deletingLastPathComponent()) - self.protectionStateToSubmitOnToggleReportDismiss = protectionStateToSubmit - userDefaults.toggleReportCounter += 1 - } - - func userScript(_ userScript: PrivacyDashboardUserScript, didRequestOpenUrlInNewTab url: URL) { - privacyDashboardDelegate?.privacyDashboardController(self, didRequestOpenUrlInNewTab: url) - } - - func userScriptDidRequestClosing(_ userScript: PrivacyDashboardUserScript) { - handleUserScriptClosing(toggleReportDismissType: .dismiss) - } - - private func handleUserScriptClosing(toggleReportDismissType: ToggleReportDismissType) { - handleDismiss(with: toggleReportDismissType, source: .userScript) + startToggleReportingFlow(with: protectionState) } - 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) - processToggleReport(for: type) - // called when protection is toggled off from privacy dashboard - } else if let protectionStateToSubmitOnToggleReportDismiss { - didChangeProtectionState(protectionStateToSubmitOnToggleReportDismiss, didSendReport: type == .send) - processToggleReport(for: type) - } - if source == .userScript { - closeDashboard() - } -#else - // macOS implementation is different here - after user taps on Send Report we don't want to trigger any action - // because of 'Thank you' screen that appears right after. - if type != .send { - if let protectionStateToSubmitOnToggleReportDismiss { - didChangeProtectionState(protectionStateToSubmitOnToggleReportDismiss) - if !didSendToggleReport { - fireToggleReportEventIfNeeded(for: type) - toggleReportsManager.recordDismissal() - } - } - closeDashboard() - } -#endif + private func startToggleReportingFlow(with protectionState: ProtectionState) { + toggleReportingFlow = ToggleReportingFlow(entryPoint: .dashboardProtectionsOff(protectionStateToSubmitOnDismiss: protectionState), + toggleReportingManager: toggleReportingManager, + controller: self) } - private func processToggleReport(for type: ToggleReportDismissType) { - fireToggleReportEventIfNeeded(for: type) - if type != .send { - toggleReportsManager.recordDismissal() - } - } - - public func handleViewWillDisappear() { - handleDismiss(with: .dismiss, source: .viewWillDisappear) - } - - private func fireToggleReportEventIfNeeded(for toggleReportDismissType: ToggleReportDismissType) { - if let eventToFire = toggleReportDismissType.event { - var parameters = [PrivacyDashboardEvents.Parameters.didOpenReportInfo: didOpenReportInfo.description] - if let toggleReportCounter { - parameters[PrivacyDashboardEvents.Parameters.toggleReportCounter] = String(toggleReportCounter) - } - eventMapping.fire(eventToFire, parameters: parameters) - } + func userScript(_ userScript: PrivacyDashboardUserScript, didRequestOpenUrlInNewTab url: URL) { + delegate?.privacyDashboardController(self, didRequestOpenUrlInNewTab: url) } - private func closeDashboard() { - privacyDashboardNavigationDelegate?.privacyDashboardControllerDidTapClose(self) + func userScriptDidRequestClose(_ userScript: PrivacyDashboardUserScript) { + toggleReportingFlow?.userScriptDidRequestClose() + delegate?.privacyDashboardControllerDidRequestClose(self) } func userScriptDidRequestShowReportBrokenSite(_ userScript: PrivacyDashboardUserScript) { - let parameters = [ + eventMapping.fire(.reportBrokenSiteShown, parameters: [ PrivacyDashboardEvents.Parameters.variant: variant.rawValue, PrivacyDashboardEvents.Parameters.source: source.rawValue - ] - eventMapping.fire(.reportBrokenSiteShown, parameters: parameters) + ]) eventMapping.fire(.showReportBrokenSite) } func userScript(_ userScript: PrivacyDashboardUserScript, setHeight height: Int) { - privacyDashboardNavigationDelegate?.privacyDashboardController(self, didSetHeight: height) + delegate?.privacyDashboardController(self, didSetHeight: height) } func userScript(_ userScript: PrivacyDashboardUserScript, didRequestSubmitBrokenSiteReportWithCategory category: String, description: String) { var parameters = [PrivacyDashboardEvents.Parameters.variant: variant.rawValue] - if case let .afterTogglePrompt(_, didToggleProtectionsFixIssue) = initDashboardMode { + if case let .afterTogglePrompt(_, didToggleProtectionsFixIssue) = entryPoint { parameters[PrivacyDashboardEvents.Parameters.didToggleProtectionsFixIssue] = didToggleProtectionsFixIssue.description } eventMapping.fire(.reportBrokenSiteSent, parameters: parameters) - privacyDashboardReportBrokenSiteDelegate?.privacyDashboardController(self, - didRequestSubmitBrokenSiteReportWithCategory: category, - description: description) + delegate?.privacyDashboardController(self, didRequestSubmitBrokenSiteReportWithCategory: category, description: description) } func userScript(_ userScript: PrivacyDashboardUserScript, didSetPermission permission: String, to state: PermissionAuthorizationState) { -#if os(macOS) - privacyDashboardDelegate?.privacyDashboardController(self, didSetPermission: permission, to: state) -#endif + delegate?.privacyDashboardController(self, didSetPermission: permission, to: state) } func userScript(_ userScript: PrivacyDashboardUserScript, setPermission permission: String, paused: Bool) { -#if os(macOS) - privacyDashboardDelegate?.privacyDashboardController(self, setPermission: permission, paused: paused) -#endif + delegate?.privacyDashboardController(self, setPermission: permission, paused: paused) } - // Toggle reports - func userScriptDidRequestToggleReportOptions(_ userScript: PrivacyDashboardUserScript) { - guard let webView = self.webView else { return } + guard let webView else { return } let site = privacyInfo?.url.trimmingQueryItemsAndFragment().absoluteString ?? "" - privacyDashboardScript.setToggleReportOptions(forSite: site, webView: webView) + script.setToggleReportOptions(forSite: site, webView: webView) } 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) + toggleReportingFlow?.userScriptDidSelectReportAction(shouldSendReport: shouldSendReport) } - public var source: BrokenSiteReport.Source { - var source: BrokenSiteReport.Source - switch initDashboardMode { - case .report: source = .appMenu - case .dashboard: source = .dashboard - case .toggleReport: source = .onProtectionsOffMenu - case .prompt(let event): source = .prompt(event) - case .afterTogglePrompt: source = .afterTogglePrompt - } - if protectionStateToSubmitOnToggleReportDismiss != nil { - source = .onProtectionsOffDashboard - } - return source - } - - func userScriptDidOpenReportInfo(_ userScript: PrivacyDashboardUserScript) { - didOpenReportInfo = true - } - - // Experiment flows + // MARK: - Experiment flows (soon to be removed) func userScript(_ userScript: PrivacyDashboardUserScript, didSelectOverallCategory category: String) { eventMapping.fire(.overallCategorySelected, parameters: [PrivacyDashboardEvents.Parameters.category: category]) } func userScript(_ userScript: PrivacyDashboardUserScript, didSelectBreakageCategory category: String) { - let parameters = [ + eventMapping.fire(.breakageCategorySelected, parameters: [ PrivacyDashboardEvents.Parameters.variant: variant.rawValue, PrivacyDashboardEvents.Parameters.category: category - ] - eventMapping.fire(.breakageCategorySelected, parameters: parameters) - privacyDashboardDelegate?.privacyDashboardController(self, didSelectBreakageCategory: category) + ]) + delegate?.privacyDashboardController(self, didSelectBreakageCategory: category) } func userScriptDidRequestShowAlertForMissingDescription(_ userScript: PrivacyDashboardUserScript) { - privacyDashboardReportBrokenSiteDelegate?.privacyDashboardControllerDidRequestShowAlertForMissingDescription(self) + delegate?.privacyDashboardControllerDidRequestShowAlertForMissingDescription(self) } func userScriptDidRequestShowNativeFeedback(_ userScript: PrivacyDashboardUserScript) { - privacyDashboardReportBrokenSiteDelegate?.privacyDashboardControllerDidRequestShowGeneralFeedback(self) + delegate?.privacyDashboardControllerDidRequestShowGeneralFeedback(self) } func userScriptDidSkipTogglingStep(_ userScript: PrivacyDashboardUserScript) { eventMapping.fire(.skipToggleStep) } + } diff --git a/Sources/PrivacyDashboard/PrivacyDashboardMode.swift b/Sources/PrivacyDashboard/PrivacyDashboardEntryPoint.swift similarity index 55% rename from Sources/PrivacyDashboard/PrivacyDashboardMode.swift rename to Sources/PrivacyDashboard/PrivacyDashboardEntryPoint.swift index a9122bfca..7f9b6042e 100644 --- a/Sources/PrivacyDashboard/PrivacyDashboardMode.swift +++ b/Sources/PrivacyDashboard/PrivacyDashboardEntryPoint.swift @@ -1,5 +1,5 @@ // -// PrivacyDashboardMode.swift +// PrivacyDashboardEntryPoint.swift // // Copyright © 2024 DuckDuckGo. All rights reserved. // @@ -16,13 +16,25 @@ // limitations under the License. // -/// Type of web page displayed -public enum PrivacyDashboardMode: Equatable { - +/// Represents the type of web page displayed within the privacy dashboard flow. +public enum PrivacyDashboardEntryPoint: Equatable { + /// The standard dashboard page that appears when the user taps on the shield icon. + /// This page displays the toggle protection option and provides information on trackers. case dashboard + + /// The report broken site screen, which is accessed from the app menu. + /// This only allows users to report issues with websites. case report - case prompt(String) + + /// The toggle report screen, which is triggered whenever the user toggles off protection (from outside of Privacy Dashboard) + /// This is only available on iOS, as macOS does not have an option to disable protection outside of the dashboard. case toggleReport(completionHandler: (Bool) -> Void) + + /// The experimental after toggle prompt screen, presented in variant B. + /// After the user toggles off protection, this prompt asks if the action helped and allows the user to report their experience. + /// - Parameters: + /// - category: The category of the issue reported by the user. + /// - didToggleProtectionsFixIssue: A Boolean indicating whether toggling protections resolved the issue. case afterTogglePrompt(category: String, didToggleProtectionsFixIssue: Bool) func screen(for variant: PrivacyDashboardVariant) -> Screen { @@ -32,20 +44,19 @@ public enum PrivacyDashboardMode: Equatable { case (.report, .control): return .breakageForm case (.report, .a): return .categorySelection case (.report, .b): return .categoryTypeSelection + case (.afterTogglePrompt, _): return .choiceBreakageForm - case (.prompt, _): return .promptBreakageForm case (.toggleReport, _): return .toggleReport } } - public static func == (lhs: PrivacyDashboardMode, rhs: PrivacyDashboardMode) -> Bool { + public static func == (lhs: PrivacyDashboardEntryPoint, rhs: PrivacyDashboardEntryPoint) -> Bool { switch (lhs, rhs) { case (.dashboard, .dashboard), (.report, .report), (.toggleReport, .toggleReport), - (.prompt, .prompt), (.afterTogglePrompt, .afterTogglePrompt): return true default: diff --git a/Sources/PrivacyDashboard/ToggleReportEvents.swift b/Sources/PrivacyDashboard/PrivacyDashboardEvents.swift similarity index 83% rename from Sources/PrivacyDashboard/ToggleReportEvents.swift rename to Sources/PrivacyDashboard/PrivacyDashboardEvents.swift index 4bd15b69b..454313255 100644 --- a/Sources/PrivacyDashboard/ToggleReportEvents.swift +++ b/Sources/PrivacyDashboard/PrivacyDashboardEvents.swift @@ -1,5 +1,5 @@ // -// ToggleReportEvents.swift +// PrivacyDashboardEvents.swift // // Copyright © 2024 DuckDuckGo. All rights reserved. // @@ -22,8 +22,6 @@ public enum PrivacyDashboardEvents { public enum Parameters { - public static let didOpenReportInfo = "didOpenReportInfo" - public static let toggleReportCounter = "toggleReportCounter" public static let variant = "variant" public static let source = "source" public static let category = "category" @@ -31,9 +29,6 @@ public enum PrivacyDashboardEvents { } - case toggleReportDoNotSend - case toggleReportDismiss - case showReportBrokenSite case reportBrokenSiteShown diff --git a/Sources/PrivacyDashboard/PrivacyDashboardURLBuilder.swift b/Sources/PrivacyDashboard/PrivacyDashboardURLBuilder.swift new file mode 100644 index 000000000..11e30afc1 --- /dev/null +++ b/Sources/PrivacyDashboard/PrivacyDashboardURLBuilder.swift @@ -0,0 +1,109 @@ +// +// PrivacyDashboardURLBuilder.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 + +final class PrivacyDashboardURLBuilder { + + enum Configuration { + + case startScreen(entryPoint: PrivacyDashboardEntryPoint, variant: PrivacyDashboardVariant) + case segueToScreen(_ screen: Screen, entryPoint: PrivacyDashboardEntryPoint) + + } + + private var url: URL + private let configuration: Configuration + + init(configuration: Configuration) { + guard let baseURL = Bundle.privacyDashboardURL else { fatalError() } + self.url = baseURL + self.configuration = configuration + } + + func build() -> URL { + url.addingScreenParameter(from: configuration) + .addingBreakageScreenParameterIfNeeded(from: configuration) + .addingCategoryParameterIfNeeded(from: configuration) + .addingOpenerParameterIfNeeded(from: configuration) + } + +} + +private extension PrivacyDashboardVariant { + + var breakageScreen: BreakageScreen? { + switch self { + case .control: return nil + case .a: return .categorySelection + case .b: return .categoryTypeSelection + } + } + +} + +private extension URL { + + private enum Constant { + + static let screenKey = "screen" + static let breakageScreenKey = "breakageScreen" + static let openerKey = "opener" + static let categoryKey = "category" + + static let menuScreenKey = "menu" + static let dashboardScreenKey = "dashboard" + + } + + func addingScreenParameter(from configuration: PrivacyDashboardURLBuilder.Configuration) -> URL { + var screen: Screen + switch configuration { + case .startScreen(let entryPoint, let variant): + screen = entryPoint.screen(for: variant) + case .segueToScreen(let destinationScreen, _): + screen = destinationScreen + } + return appendingParameter(name: Constant.screenKey, value: screen.rawValue) + } + + func addingBreakageScreenParameterIfNeeded(from configuration: PrivacyDashboardURLBuilder.Configuration) -> URL { + if case .startScreen(_, let variant) = configuration, let breakageScreen = variant.breakageScreen?.rawValue { + return appendingParameter(name: Constant.breakageScreenKey, value: breakageScreen) + } + return self + } + + func addingCategoryParameterIfNeeded(from configuration: PrivacyDashboardURLBuilder.Configuration) -> URL { + if case .startScreen(let entryPoint, _) = configuration, case .afterTogglePrompt(let category, _) = entryPoint { + return appendingParameter(name: Constant.categoryKey, value: category) + } + return self + } + + func addingOpenerParameterIfNeeded(from configuration: PrivacyDashboardURLBuilder.Configuration) -> URL { + if case .startScreen(let entryPoint, _) = configuration, case .toggleReport = entryPoint { + return appendingParameter(name: Constant.openerKey, value: Constant.menuScreenKey) + } + if case .segueToScreen(_, let entryPoint) = configuration, entryPoint == .dashboard { + return appendingParameter(name: Constant.openerKey, value: Constant.dashboardScreenKey) + } + return self + } + +} diff --git a/Sources/PrivacyDashboard/PrivacyDashboardUserScript.swift b/Sources/PrivacyDashboard/PrivacyDashboardUserScript.swift index bdde66f0e..5c8ea5d96 100644 --- a/Sources/PrivacyDashboard/PrivacyDashboardUserScript.swift +++ b/Sources/PrivacyDashboard/PrivacyDashboardUserScript.swift @@ -28,7 +28,7 @@ protocol PrivacyDashboardUserScriptDelegate: AnyObject { func userScript(_ userScript: PrivacyDashboardUserScript, didChangeProtectionState protectionState: ProtectionState) func userScript(_ userScript: PrivacyDashboardUserScript, setHeight height: Int) - func userScriptDidRequestClosing(_ userScript: PrivacyDashboardUserScript) + func userScriptDidRequestClose(_ userScript: PrivacyDashboardUserScript) func userScriptDidRequestShowReportBrokenSite(_ userScript: PrivacyDashboardUserScript) func userScript(_ userScript: PrivacyDashboardUserScript, didRequestSubmitBrokenSiteReportWithCategory category: String, description: String) func userScript(_ userScript: PrivacyDashboardUserScript, didRequestOpenUrlInNewTab: URL) @@ -38,7 +38,7 @@ protocol PrivacyDashboardUserScriptDelegate: AnyObject { // Toggle reports func userScriptDidRequestToggleReportOptions(_ userScript: PrivacyDashboardUserScript) func userScript(_ userScript: PrivacyDashboardUserScript, didSelectReportAction shouldSendReport: Bool) - func userScriptDidOpenReportInfo(_ userScript: PrivacyDashboardUserScript) + // Experiment flows func userScript(_ userScript: PrivacyDashboardUserScript, didSelectOverallCategory category: String) func userScript(_ userScript: PrivacyDashboardUserScript, didSelectBreakageCategory category: String) @@ -55,7 +55,7 @@ public enum PrivacyDashboardTheme: String, Encodable { } -public enum Screen: String, Decodable { +public enum Screen: String, Decodable, CaseIterable { case primaryScreen @@ -127,7 +127,6 @@ final class PrivacyDashboardUserScript: NSObject, StaticUserScript { case privacyDashboardGetToggleReportOptions case privacyDashboardSendToggleReport case privacyDashboardRejectToggleReport - case privacyDashboardSeeWhatIsSent case privacyDashboardShowAlertForMissingDescription case privacyDashboardShowNativeFeedback @@ -141,11 +140,6 @@ final class PrivacyDashboardUserScript: NSObject, StaticUserScript { var messageNames: [String] { MessageNames.allCases.map(\.rawValue) } weak var delegate: PrivacyDashboardUserScriptDelegate? - private let privacyConfigurationManager: PrivacyConfigurationManaging - - init(privacyConfigurationManager: PrivacyConfigurationManaging) { - self.privacyConfigurationManager = privacyConfigurationManager - } // swiftlint:disable:next cyclomatic_complexity func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { @@ -179,8 +173,6 @@ final class PrivacyDashboardUserScript: NSObject, StaticUserScript { handleSendToggleReport() case .privacyDashboardRejectToggleReport: handleDoNotSendToggleReport() - case .privacyDashboardSeeWhatIsSent: - handleDidOpenReportInfo() case .privacyDashboardShowAlertForMissingDescription: handleShowAlertForMissingDescription() case .privacyDashboardShowNativeFeedback: @@ -211,12 +203,11 @@ final class PrivacyDashboardUserScript: NSObject, StaticUserScript { assertionFailure("privacyDashboardSetHeight: expected height to be an Int") return } - delegate?.userScript(self, setHeight: height) } private func handleClose() { - delegate?.userScriptDidRequestClosing(self) + delegate?.userScriptDidRequestClose(self) } private func handleShowReportBrokenSite() { @@ -295,10 +286,6 @@ final class PrivacyDashboardUserScript: NSObject, StaticUserScript { delegate?.userScript(self, didSelectReportAction: false) } - private func handleDidOpenReportInfo() { - delegate?.userScriptDidOpenReportInfo(self) - } - private func handleSelectOverallCategory(message: WKScriptMessage) { guard let dict = message.body as? [String: Any], let category = dict["category"] as? String @@ -373,8 +360,6 @@ final class PrivacyDashboardUserScript: NSObject, StaticUserScript { {"id": "httpErrorCodes"}, {"id": "reportFlow"}, {"id": "lastSentDay"}, - {"id": "didOpenReportInfo"}, - {"id": "toggleReportCounter"}, {"id": "jsPerformance"}, {"id": "openerContext"}, {"id": "userRefreshCount"}, @@ -430,7 +415,6 @@ final class PrivacyDashboardUserScript: NSObject, StaticUserScript { assertionFailure("Can't encode themeName into JSON") return } - evaluate(js: "window.onChangeTheme(\(themeJson))", in: webView) } @@ -439,7 +423,6 @@ final class PrivacyDashboardUserScript: NSObject, StaticUserScript { assertionFailure("Can't encode serverTrustViewModel into JSON") return } - evaluate(js: "window.onChangeCertificateData(\(certificateDataJson))", in: webView) } @@ -472,8 +455,7 @@ final class PrivacyDashboardUserScript: NSObject, StaticUserScript { assertionFailure("PrivacyDashboardUserScript: could not serialize permissions object") return } - - self.evaluate(js: "window.onChangeAllowedPermissions(\(allowedPermissionsJson))", in: webView) + evaluate(js: "window.onChangeAllowedPermissions(\(allowedPermissionsJson))", in: webView) } private func evaluate(js: String, in webView: WKWebView) { @@ -485,7 +467,7 @@ final class PrivacyDashboardUserScript: NSObject, StaticUserScript { extension Data { func utf8String() -> String? { - return String(data: self, encoding: .utf8) + String(data: self, encoding: .utf8) } } diff --git a/Sources/PrivacyDashboard/ToggleReportingConfiguration.swift b/Sources/PrivacyDashboard/ToggleReportingConfiguration.swift new file mode 100644 index 000000000..9cb2c0df6 --- /dev/null +++ b/Sources/PrivacyDashboard/ToggleReportingConfiguration.swift @@ -0,0 +1,37 @@ +// +// ToggleReportingConfiguration.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import BrowserServicesKit + +public struct ToggleReportingConfiguration { + + let isEnabled: Bool + let settings: PrivacyConfigurationData.PrivacyFeature.FeatureSettings + + public init(isEnabled: Bool, settings: PrivacyConfigurationData.PrivacyFeature.FeatureSettings) { + self.isEnabled = isEnabled + self.settings = settings + } + + public init(privacyConfigurationManager: PrivacyConfigurationManaging) { + let privacyConfig = privacyConfigurationManager.privacyConfig + isEnabled = privacyConfig.isEnabled(featureKey: .toggleReports) + settings = privacyConfig.settings(for: .toggleReports) + } + +} diff --git a/Sources/PrivacyDashboard/ToggleReportsFeature.swift b/Sources/PrivacyDashboard/ToggleReportingFeature.swift similarity index 84% rename from Sources/PrivacyDashboard/ToggleReportsFeature.swift rename to Sources/PrivacyDashboard/ToggleReportingFeature.swift index 9e327f82d..d7728d8df 100644 --- a/Sources/PrivacyDashboard/ToggleReportsFeature.swift +++ b/Sources/PrivacyDashboard/ToggleReportingFeature.swift @@ -1,5 +1,5 @@ // -// ToggleReportsFeature.swift +// ToggleReportingFeature.swift // // Copyright © 2024 DuckDuckGo. All rights reserved. // @@ -17,7 +17,6 @@ // import Foundation -import Combine import BrowserServicesKit public protocol ToggleReporting { @@ -33,7 +32,7 @@ public protocol ToggleReporting { } -public final class ToggleReportsFeature: ToggleReporting { +public final class ToggleReportingFeature: ToggleReporting { enum Constants { @@ -44,7 +43,7 @@ public final class ToggleReportsFeature: ToggleReporting { static let promptIntervalKey = "promptInterval" static let maxPromptCountKey = "maxPromptCount" - static let defaultTimeInterval: TimeInterval = 48 * 60 * 60 // Two days + static let defaultTimeInterval: TimeInterval = 48 * 60 * 60 // 2 days static let defaultPromptCount = 3 } @@ -58,11 +57,10 @@ public final class ToggleReportsFeature: ToggleReporting { public private(set) var promptInterval: TimeInterval = 0 public private(set) var maxPromptCount: Int = 0 - public init(manager: PrivacyConfigurationManaging) { - let isCurrentLanguageEnglish = Locale.current.languageCode == "en" - isEnabled = manager.privacyConfig.isEnabled(featureKey: .toggleReports) && isCurrentLanguageEnglish + public init(toggleReportingConfiguration: ToggleReportingConfiguration) { + isEnabled = toggleReportingConfiguration.isEnabled guard isEnabled else { return } - let settings = manager.privacyConfig.settings(for: .toggleReports) + let settings = toggleReportingConfiguration.settings isDismissLogicEnabled = settings[Constants.dismissLogicEnabledKey] as? Bool ?? false dismissInterval = settings[Constants.dismissIntervalKey] as? TimeInterval ?? Constants.defaultTimeInterval isPromptLimitLogicEnabled = settings[Constants.promptLimitLogicEnabledKey] as? Bool ?? false diff --git a/Sources/PrivacyDashboard/ToggleReportingFlow.swift b/Sources/PrivacyDashboard/ToggleReportingFlow.swift new file mode 100644 index 000000000..51dd4a3a7 --- /dev/null +++ b/Sources/PrivacyDashboard/ToggleReportingFlow.swift @@ -0,0 +1,103 @@ +// +// ToggleReportingFlow.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 + +@MainActor +final class ToggleReportingFlow { + + enum EntryPoint { + + case appMenuProtectionsOff(completionHandler: (Bool) -> Void) + case dashboardProtectionsOff(protectionStateToSubmitOnDismiss: ProtectionState) + + var source: BrokenSiteReport.Source { + switch self { + case .appMenuProtectionsOff: return .onProtectionsOffMenu + case .dashboardProtectionsOff: return .onProtectionsOffDashboard + } + } + + } + + // iOS and macOS implementations differ here: on macOS app, after the user taps 'Send Report', + // no immediate action should be triggered due to the subsequent 'Thank you' screen. + // Processing of the toggle report should only proceed once the user dismisses the 'Thank you' screen. +#if os(iOS) + var shouldHandlePendingProtectionStateChangeOnReportSent: Bool = true +#else + var shouldHandlePendingProtectionStateChangeOnReportSent: Bool = false +#endif + + weak var privacyDashboardController: PrivacyDashboardController? + var toggleReportingManager: ToggleReportingManaging + + let entryPoint: EntryPoint + + init(entryPoint: EntryPoint, + toggleReportingManager: ToggleReportingManaging, + controller: PrivacyDashboardController?) { + self.entryPoint = entryPoint + self.toggleReportingManager = toggleReportingManager + self.privacyDashboardController = controller + } + + func handleViewWillDisappear() { + handleDismissal(isUserAction: true) + } + + func userScriptDidRequestClose() { + handleDismissal() + } + + func userScriptDidSelectReportAction(shouldSendReport: Bool) { + if shouldSendReport { + handleSendReport() + } else { + handleDismissal() + } + } + + private func handleDismissal(isUserAction: Bool = false) { + toggleReportingManager.recordDismissal(date: Date()) + switch entryPoint { + case .appMenuProtectionsOff(let completionHandler): + completionHandler(false) + case .dashboardProtectionsOff(let protectionStateToSubmitOnDismiss): + privacyDashboardController?.didChangeProtectionState(to: protectionStateToSubmitOnDismiss, didSendReport: false) + } + if !isUserAction { + privacyDashboardController?.didRequestClose() + } + } + + private func handleSendReport() { + privacyDashboardController?.didRequestSubmitToggleReport(with: entryPoint.source) + toggleReportingManager.recordPrompt(date: Date()) + switch entryPoint { + case .appMenuProtectionsOff(let completionHandler): + completionHandler(true) + case .dashboardProtectionsOff(let protectionStateToSubmitOnDismiss): + if shouldHandlePendingProtectionStateChangeOnReportSent { + privacyDashboardController?.didChangeProtectionState(to: protectionStateToSubmitOnDismiss, didSendReport: true) + privacyDashboardController?.didRequestClose() + } + } + } + +} diff --git a/Sources/PrivacyDashboard/ToggleReportsManager.swift b/Sources/PrivacyDashboard/ToggleReportingManager.swift similarity index 87% rename from Sources/PrivacyDashboard/ToggleReportsManager.swift rename to Sources/PrivacyDashboard/ToggleReportingManager.swift index b481a0f5e..ec46f06af 100644 --- a/Sources/PrivacyDashboard/ToggleReportsManager.swift +++ b/Sources/PrivacyDashboard/ToggleReportingManager.swift @@ -1,5 +1,5 @@ // -// ToggleReportsManager.swift +// ToggleReportingManager.swift // // Copyright © 2024 DuckDuckGo. All rights reserved. // @@ -20,7 +20,7 @@ import Foundation import BrowserServicesKit import Persistence -public protocol ToggleReportsStoring { +public protocol ToggleReportingStoring { var dismissedAt: Date? { get set } var promptWindowStart: Date? { get set } @@ -28,7 +28,7 @@ public protocol ToggleReportsStoring { } -public struct ToggleReportsStore: ToggleReportsStoring { +public struct ToggleReportingStore: ToggleReportingStoring { private enum Key { @@ -60,12 +60,21 @@ public struct ToggleReportsStore: ToggleReportsStoring { } -public struct ToggleReportsManager { +public protocol ToggleReportingManaging { + + mutating func recordDismissal(date: Date) + mutating func recordPrompt(date: Date) + + var shouldShowToggleReport: Bool { get } + +} + +public struct ToggleReportingManager: ToggleReportingManaging { private let feature: ToggleReporting - private var store: ToggleReportsStoring + private var store: ToggleReportingStoring - public init(feature: ToggleReporting, store: ToggleReportsStoring = ToggleReportsStore()) { + public init(feature: ToggleReporting, store: ToggleReportingStoring = ToggleReportingStore()) { self.store = store self.feature = feature } diff --git a/Tests/BrowserServicesKitTests/ContentBlocker/UserContentControllerTests.swift b/Tests/BrowserServicesKitTests/ContentBlocker/UserContentControllerTests.swift index 622385ba3..470aae6ae 100644 --- a/Tests/BrowserServicesKitTests/ContentBlocker/UserContentControllerTests.swift +++ b/Tests/BrowserServicesKitTests/ContentBlocker/UserContentControllerTests.swift @@ -262,7 +262,6 @@ class PrivacyConfigurationManagerMock: PrivacyConfigurationManaging { let updatesPublisher: AnyPublisher var privacyConfig: PrivacyConfiguration = PrivacyConfigurationMock() let internalUserDecider: InternalUserDecider = DefaultInternalUserDecider() - var toggleProtectionsCounter = ToggleProtectionsCounter(eventReporting: EventMapping { _, _, _, _ in }) func reload(etag: String?, data: Data?) -> PrivacyConfigurationManager.ReloadResult { .downloaded } diff --git a/Tests/BrowserServicesKitTests/ContentBlocker/WebViewTestHelper.swift b/Tests/BrowserServicesKitTests/ContentBlocker/WebViewTestHelper.swift index bf47acf94..31370ce4a 100644 --- a/Tests/BrowserServicesKitTests/ContentBlocker/WebViewTestHelper.swift +++ b/Tests/BrowserServicesKitTests/ContentBlocker/WebViewTestHelper.swift @@ -197,8 +197,7 @@ final class WebKitTestHelper { return AppPrivacyConfiguration(data: privacyData, identifier: "", localProtection: localProtection, - internalUserDecider: DefaultInternalUserDecider(), - toggleProtectionsCounter: ToggleProtectionsCounter(eventReporting: nil)) + internalUserDecider: DefaultInternalUserDecider()) } static func prepareContentBlockingRules(trackerData: TrackerData, diff --git a/Tests/BrowserServicesKitTests/GPC/GPCTests.swift b/Tests/BrowserServicesKitTests/GPC/GPCTests.swift index b5f5ef066..1eee4af76 100644 --- a/Tests/BrowserServicesKitTests/GPC/GPCTests.swift +++ b/Tests/BrowserServicesKitTests/GPC/GPCTests.swift @@ -42,8 +42,7 @@ final class GPCTests: XCTestCase { appConfig = AppPrivacyConfiguration(data: privacyData, identifier: "", localProtection: localProtection, - internalUserDecider: DefaultInternalUserDecider(), - toggleProtectionsCounter: ToggleProtectionsCounter(eventReporting: nil)) + internalUserDecider: DefaultInternalUserDecider()) } func testWhenGPCEnableDomainIsHttpThenISGPCEnabledTrue() { diff --git a/Tests/BrowserServicesKitTests/PrivacyConfig/PrivacyConfigurationReferenceTests.swift b/Tests/BrowserServicesKitTests/PrivacyConfig/PrivacyConfigurationReferenceTests.swift index 1484a3099..2eed811a6 100644 --- a/Tests/BrowserServicesKitTests/PrivacyConfig/PrivacyConfigurationReferenceTests.swift +++ b/Tests/BrowserServicesKitTests/PrivacyConfig/PrivacyConfigurationReferenceTests.swift @@ -42,8 +42,7 @@ final class PrivacyConfigurationReferenceTests: XCTestCase { let privacyConfiguration = AppPrivacyConfiguration(data: privacyConfigurationData, identifier: UUID().uuidString, localProtection: MockDomainsProtectionStore(), - internalUserDecider: DefaultInternalUserDecider(), - toggleProtectionsCounter: ToggleProtectionsCounter(eventReporting: nil)) + internalUserDecider: DefaultInternalUserDecider()) for test in testConfig.tests { if test.exceptPlatforms.contains(.macosBrowser) || test.exceptPlatforms.contains(.iosBrowser) { os_log("Skipping test %@", test.name) diff --git a/Tests/BrowserServicesKitTests/Subscription/SubscriptionFeatureAvailabilityTests.swift b/Tests/BrowserServicesKitTests/Subscription/SubscriptionFeatureAvailabilityTests.swift index a20d3dd35..cb3cfbc7f 100644 --- a/Tests/BrowserServicesKitTests/Subscription/SubscriptionFeatureAvailabilityTests.swift +++ b/Tests/BrowserServicesKitTests/Subscription/SubscriptionFeatureAvailabilityTests.swift @@ -253,7 +253,6 @@ class MockPrivacyConfigurationManager: PrivacyConfigurationManaging { let updatesPublisher: AnyPublisher var privacyConfig: PrivacyConfiguration let internalUserDecider: InternalUserDecider - var toggleProtectionsCounter = ToggleProtectionsCounter(eventReporting: EventMapping { _, _, _, _ in }) func reload(etag: String?, data: Data?) -> PrivacyConfigurationManager.ReloadResult { .downloaded } diff --git a/Tests/DDGSyncTests/Mocks/Mocks.swift b/Tests/DDGSyncTests/Mocks/Mocks.swift index b4cd4e896..1bbd371c1 100644 --- a/Tests/DDGSyncTests/Mocks/Mocks.swift +++ b/Tests/DDGSyncTests/Mocks/Mocks.swift @@ -151,7 +151,6 @@ class MockPrivacyConfigurationManager: PrivacyConfigurationManaging { let updatesPublisher: AnyPublisher var privacyConfig: PrivacyConfiguration = MockPrivacyConfiguration() let internalUserDecider: InternalUserDecider = DefaultInternalUserDecider() - var toggleProtectionsCounter = ToggleProtectionsCounter(eventReporting: EventMapping { _, _, _, _ in }) func reload(etag: String?, data: Data?) -> PrivacyConfigurationManager.ReloadResult { .downloaded } diff --git a/Tests/PrivacyDashboardTests/BrokenSiteReportMocks.swift b/Tests/PrivacyDashboardTests/BrokenSiteReportMocks.swift index a3c942cbb..9721f5108 100644 --- a/Tests/PrivacyDashboardTests/BrokenSiteReportMocks.swift +++ b/Tests/PrivacyDashboardTests/BrokenSiteReportMocks.swift @@ -46,8 +46,6 @@ struct BrokenSiteReportMocks { vpnOn: false, jsPerformance: nil, userRefreshCount: 0, - didOpenReportInfo: true, - toggleReportCounter: 0, variant: "") #else BrokenSiteReport(siteUrl: URL(string: "https://duckduckgo.com")!, @@ -69,9 +67,7 @@ struct BrokenSiteReportMocks { openerContext: nil, vpnOn: false, jsPerformance: nil, - userRefreshCount: 0, - didOpenReportInfo: true, - toggleReportCounter: 0) + userRefreshCount: 0) #endif } @@ -100,8 +96,6 @@ struct BrokenSiteReportMocks { vpnOn: false, jsPerformance: nil, userRefreshCount: 0, - didOpenReportInfo: true, - toggleReportCounter: 0, variant: "") #else BrokenSiteReport(siteUrl: URL(string: "https://somethingelse.zz")!, @@ -123,9 +117,7 @@ struct BrokenSiteReportMocks { openerContext: nil, vpnOn: false, jsPerformance: nil, - userRefreshCount: 0, - didOpenReportInfo: true, - toggleReportCounter: 0) + userRefreshCount: 0) #endif } @@ -154,8 +146,6 @@ struct BrokenSiteReportMocks { vpnOn: false, jsPerformance: nil, userRefreshCount: 0, - didOpenReportInfo: true, - toggleReportCounter: 0, variant: "") #else BrokenSiteReport(siteUrl: URL(string: "https://www.subdomain.example.com/some/pathname?t=param#aaa")!, @@ -177,9 +167,7 @@ struct BrokenSiteReportMocks { openerContext: nil, vpnOn: false, jsPerformance: nil, - userRefreshCount: 0, - didOpenReportInfo: true, - toggleReportCounter: 0) + userRefreshCount: 0) #endif } } diff --git a/Tests/PrivacyDashboardTests/Mocks/PrivacyDashboardDelegateMock.swift b/Tests/PrivacyDashboardTests/Mocks/PrivacyDashboardDelegateMock.swift new file mode 100644 index 000000000..e9378295c --- /dev/null +++ b/Tests/PrivacyDashboardTests/Mocks/PrivacyDashboardDelegateMock.swift @@ -0,0 +1,64 @@ +// +// PrivacyDashboardDelegateMock.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 PrivacyDashboard + +final class PrivacyDashboardDelegateMock: PrivacyDashboardControllerDelegate { + + var didChangeProtectionSwitchCalled = false + var protectionState: ProtectionState? + var didSendReport = false + var didRequestCloseCalled = false + var didRequestSubmitToggleReport = false + + func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, + didChangeProtectionSwitch protectionState: ProtectionState, + didSendReport: Bool) { + didChangeProtectionSwitchCalled = true + self.protectionState = protectionState + self.didSendReport = didSendReport + + } + func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, + didRequestSubmitToggleReportWithSource source: BrokenSiteReport.Source) { + didRequestSubmitToggleReport = true + } + + func privacyDashboardControllerDidRequestClose(_ privacyDashboardController: PrivacyDashboardController) { + didRequestCloseCalled = true + } + + // not under tests + + func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, didRequestOpenUrlInNewTab url: URL) {} + func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, + didRequestOpenSettings target: PrivacyDashboardOpenSettingsTarget) {} + func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, didSelectBreakageCategory category: String) {} + func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, + didRequestSubmitBrokenSiteReportWithCategory category: String, + description: String) {} + func privacyDashboardControllerDidRequestShowAlertForMissingDescription(_ privacyDashboardController: PrivacyDashboardController) {} + func privacyDashboardControllerDidRequestShowGeneralFeedback(_ privacyDashboardController: PrivacyDashboardController) {} + func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, didSetHeight height: Int) {} + func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, + didSetPermission permissionName: String, + to state: PermissionAuthorizationState) {} + func privacyDashboardController(_ privacyDashboardController: PrivacyDashboardController, setPermission permissionName: String, paused: Bool) { } + +} diff --git a/Sources/BrowserServicesKit/ContentBlocking/ToggleProtectionsCounterStore.swift b/Tests/PrivacyDashboardTests/Mocks/ToggleReportingManagerMock.swift similarity index 52% rename from Sources/BrowserServicesKit/ContentBlocking/ToggleProtectionsCounterStore.swift rename to Tests/PrivacyDashboardTests/Mocks/ToggleReportingManagerMock.swift index 7005a5819..0f748e2eb 100644 --- a/Sources/BrowserServicesKit/ContentBlocking/ToggleProtectionsCounterStore.swift +++ b/Tests/PrivacyDashboardTests/Mocks/ToggleReportingManagerMock.swift @@ -1,5 +1,5 @@ // -// ToggleProtectionsCounterStore.swift +// ToggleReportingManagerMock.swift // // Copyright © 2024 DuckDuckGo. All rights reserved. // @@ -17,16 +17,21 @@ // import Foundation -import Persistence +import PrivacyDashboard -public struct ToggleProtectionsCounterStore: KeyValueStoring { +final class ToggleReportingManagerMock: ToggleReportingManaging { - private var userDefaults: UserDefaults? { UserDefaults(suiteName: "com.duckduckgo.app.toggleProtectionsCounter") } + var recordDismissalCalled: Bool = false + var recordPromptCalled: Bool = false - public init() {} + func recordDismissal(date: Date) { + recordDismissalCalled = true + } - public func object(forKey defaultName: String) -> Any? { userDefaults?.object(forKey: defaultName) } - public func set(_ value: Any?, forKey defaultName: String) { userDefaults?.set(value, forKey: defaultName) } - public func removeObject(forKey defaultName: String) { userDefaults?.removeObject(forKey: defaultName) } + func recordPrompt(date: Date) { + recordPromptCalled = true + } + + var shouldShowToggleReport: Bool { return true } } diff --git a/Tests/PrivacyDashboardTests/PrivacyDashboardControllerTests.swift b/Tests/PrivacyDashboardTests/PrivacyDashboardControllerTests.swift new file mode 100644 index 000000000..b55439f1b --- /dev/null +++ b/Tests/PrivacyDashboardTests/PrivacyDashboardControllerTests.swift @@ -0,0 +1,259 @@ +// +// PrivacyDashboardControllerTests.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +import Common +import WebKit +@testable import PrivacyDashboard +@testable import BrowserServicesKit + +@MainActor +final class PrivacyDashboardControllerTests: XCTestCase { + + var privacyDashboardController: PrivacyDashboardController! + var delegateMock: PrivacyDashboardDelegateMock! + var toggleReportingManagerMock: ToggleReportingManagerMock! + var webView: WKWebView! + + private func makePrivacyDashboardController(entryPoint: PrivacyDashboardEntryPoint) { + delegateMock = PrivacyDashboardDelegateMock() + toggleReportingManagerMock = ToggleReportingManagerMock() + privacyDashboardController = PrivacyDashboardController(privacyInfo: nil, + entryPoint: entryPoint, + variant: .control, + toggleReportingManager: toggleReportingManagerMock, + eventMapping: EventMapping { _, _, _, _ in }) + webView = WKWebView() + privacyDashboardController.setup(for: webView) + privacyDashboardController.delegate = delegateMock + } + + // MARK: - Setup + + func testOpenCorrectURL() { + let entryPoints: [PrivacyDashboardEntryPoint] = [ + .dashboard, + .report, + .afterTogglePrompt(category: "apple", didToggleProtectionsFixIssue: false), + .toggleReport(completionHandler: { _ in }) + ] + for entryPoint in entryPoints { + makePrivacyDashboardController(entryPoint: entryPoint) + let currentURL = privacyDashboardController.webView!.url + XCTAssertEqual(currentURL?.getParameter(named: "screen"), entryPoint.screen(for: .control).rawValue) + if case .afterTogglePrompt = entryPoint { + XCTAssertEqual(currentURL?.getParameter(named: "category"), "apple") + } + if case .toggleReport = entryPoint { + XCTAssertEqual(currentURL?.getParameter(named: "opener"), "menu") + } + } + } + + // MARK: - didChangeProtectionState + + func testUserScriptDidDisableProtectionStateNotFromPrimaryScreenShouldNotSegueToToggleReportScreen() { + makePrivacyDashboardController(entryPoint: .dashboard) + let allScreensButPrimaryScreen = Screen.allCases.filter { $0 != .primaryScreen } + for screen in allScreensButPrimaryScreen { + let protectionState = ProtectionState(isProtected: false, eventOrigin: .init(screen: screen)) + privacyDashboardController.userScript(PrivacyDashboardUserScript(), didChangeProtectionState: protectionState) + XCTAssertTrue(delegateMock.didChangeProtectionSwitchCalled) + XCTAssertFalse(delegateMock.protectionState!.isProtected) + XCTAssertFalse(delegateMock.didSendReport) + XCTAssertTrue(delegateMock.didRequestCloseCalled) + } + } + + func testUserScriptDidEnableProtectionStateShouldNotSegueToToggleReportScreen() { + makePrivacyDashboardController(entryPoint: .dashboard) + simulateProtectionToggleSwitch(true) + XCTAssertTrue(delegateMock.didChangeProtectionSwitchCalled) + XCTAssertTrue(delegateMock.protectionState!.isProtected) + XCTAssertFalse(delegateMock.didSendReport) + XCTAssertTrue(delegateMock.didRequestCloseCalled) + } + + private func simulateProtectionToggleSwitch(_ isProtected: Bool) { + let protectionState = ProtectionState(isProtected: isProtected, eventOrigin: .init(screen: .primaryScreen)) + privacyDashboardController.userScript(PrivacyDashboardUserScript(), didChangeProtectionState: protectionState) + } + + func testUserScriptDidDisableProtectionStateShouldSegueToToggleReportScreen() { + makePrivacyDashboardController(entryPoint: .dashboard) + simulateProtectionToggleSwitch(false) + XCTAssertFalse(delegateMock.didChangeProtectionSwitchCalled) + XCTAssertFalse(delegateMock.didRequestCloseCalled) + let currentURL = privacyDashboardController.webView!.url + XCTAssertEqual(currentURL?.getParameter(named: "screen"), "toggleReport") + XCTAssertEqual(currentURL?.getParameter(named: "opener"), "dashboard") + } + + // MARK: - userScriptDidRequestClose + + func testUserScriptDidRequestCloseShouldCallDidRequestClose() { + makePrivacyDashboardController(entryPoint: .dashboard) + privacyDashboardController.userScriptDidRequestClose(PrivacyDashboardUserScript()) + XCTAssertTrue(delegateMock.didRequestCloseCalled) + } + + func testUserScriptDidRequestCloseIfEntryPointIsToggleReportShouldCallCompletionHandler() { + func completionHandler(didSendReport: Bool) { + XCTAssertFalse(didSendReport) + } + makePrivacyDashboardController(entryPoint: .toggleReport(completionHandler: completionHandler(didSendReport:))) + privacyDashboardController.userScriptDidRequestClose(PrivacyDashboardUserScript()) + } + + func testUserScriptDidRequestCloseIfThereIsProtectionStateToSubmitShouldCallDidChangeProtectionSwitch() { + makePrivacyDashboardController(entryPoint: .dashboard) + simulateProtectionToggleSwitch(false) + XCTAssertFalse(delegateMock.didChangeProtectionSwitchCalled) + privacyDashboardController.userScriptDidRequestClose(PrivacyDashboardUserScript()) + XCTAssertTrue(delegateMock.didChangeProtectionSwitchCalled) + XCTAssertFalse(delegateMock.didSendReport) + XCTAssertTrue(delegateMock.didRequestCloseCalled) + } + + func testUserScriptDidRequestCloseIfNotInToggleReportFlowShouldNotRecordToggleDismissal() { + makePrivacyDashboardController(entryPoint: .dashboard) + privacyDashboardController.userScriptDidRequestClose(PrivacyDashboardUserScript()) + XCTAssertFalse(toggleReportingManagerMock.recordDismissalCalled) + XCTAssertFalse(toggleReportingManagerMock.recordPromptCalled) + } + + func testUserScriptDidRequestCloseIfEntryPointIsToggleReportShouldRecordToggleDismissal() { + makePrivacyDashboardController(entryPoint: .toggleReport(completionHandler: { _ in })) + privacyDashboardController.userScriptDidRequestClose(PrivacyDashboardUserScript()) + XCTAssertTrue(toggleReportingManagerMock.recordDismissalCalled) + XCTAssertFalse(toggleReportingManagerMock.recordPromptCalled) + } + + func testUserScriptDidRequestCloseIfInToggleReportFlowShouldRecordToggleDismissal() { + makePrivacyDashboardController(entryPoint: .dashboard) + simulateProtectionToggleSwitch(false) + privacyDashboardController.userScriptDidRequestClose(PrivacyDashboardUserScript()) + XCTAssertTrue(toggleReportingManagerMock.recordDismissalCalled) + XCTAssertFalse(toggleReportingManagerMock.recordPromptCalled) + } + + // MARK: - userScriptDidSelectReportAction + + // MARK: (do not send) + + func testUserScriptDidSelectReportActionDoNotSendShouldRecordDismissal() { + makePrivacyDashboardController(entryPoint: .toggleReport(completionHandler: { _ in })) + privacyDashboardController.userScript(PrivacyDashboardUserScript(), didSelectReportAction: false) + XCTAssertTrue(toggleReportingManagerMock.recordDismissalCalled) + XCTAssertFalse(toggleReportingManagerMock.recordPromptCalled) + } + + func testUserScriptDidSelectReportActionDoNotSendIfEntryPointIsToggleReportShouldCallCompletionHandler() { + func completionHandler(didSendReport: Bool) { + XCTAssertFalse(didSendReport) + } + makePrivacyDashboardController(entryPoint: .toggleReport(completionHandler: completionHandler(didSendReport:))) + privacyDashboardController.userScript(PrivacyDashboardUserScript(), didSelectReportAction: false) + XCTAssertTrue(delegateMock.didRequestCloseCalled) + } + + func testUserScriptDidSelectReportActionDoNotSendIfThereIsProtectionStateToSubmitShouldCallDidChangeProtectionSwitch() { + makePrivacyDashboardController(entryPoint: .dashboard) + simulateProtectionToggleSwitch(false) + privacyDashboardController.userScript(PrivacyDashboardUserScript(), didSelectReportAction: false) + XCTAssertTrue(delegateMock.didChangeProtectionSwitchCalled) + XCTAssertFalse(delegateMock.didSendReport) + XCTAssertTrue(delegateMock.didRequestCloseCalled) + } + + // MARK: (send) + + func testUserScriptDidSelectReportActionSendShouldRecordPrompt() { + makePrivacyDashboardController(entryPoint: .toggleReport(completionHandler: { _ in })) + privacyDashboardController.userScript(PrivacyDashboardUserScript(), didSelectReportAction: true) + XCTAssertTrue(toggleReportingManagerMock.recordPromptCalled) + XCTAssertFalse(toggleReportingManagerMock.recordDismissalCalled) + } + + func testUserScriptDidSelectReportActionSendShouldCallDidRequestSubmitToggleReport() { + makePrivacyDashboardController(entryPoint: .toggleReport(completionHandler: { _ in })) + privacyDashboardController.userScript(PrivacyDashboardUserScript(), didSelectReportAction: true) + XCTAssertTrue(delegateMock.didRequestSubmitToggleReport) + } + + func testUserScriptDidSelectReportActionSendIfEntryPointIsToggleReportShouldCallCompletionHandler() { + func completionHandler(didSendReport: Bool) { + XCTAssertTrue(didSendReport) + } + makePrivacyDashboardController(entryPoint: .toggleReport(completionHandler: completionHandler(didSendReport:))) + privacyDashboardController.userScript(PrivacyDashboardUserScript(), didSelectReportAction: true) + } + + func testUserScriptDidSelectReportActionSendIfThereIsProtectionStateToSubmitShouldCallDidChangeProtectionSwitchOnIOSApp() { + makePrivacyDashboardController(entryPoint: .dashboard) + simulateProtectionToggleSwitch(false) + privacyDashboardController.toggleReportingFlow?.shouldHandlePendingProtectionStateChangeOnReportSent = true // simulate iOS app + privacyDashboardController.userScript(PrivacyDashboardUserScript(), didSelectReportAction: true) + XCTAssertTrue(delegateMock.didChangeProtectionSwitchCalled) + XCTAssertTrue(delegateMock.didSendReport) + XCTAssertTrue(delegateMock.didRequestCloseCalled) + } + + func testUserScriptDidSelectReportActionSendIfThereIsProtectionStateToSubmitShouldNotCallDidChangeProtectionSwitchOnMacOSApp() { + makePrivacyDashboardController(entryPoint: .dashboard) + simulateProtectionToggleSwitch(false) + privacyDashboardController.toggleReportingFlow?.shouldHandlePendingProtectionStateChangeOnReportSent = false // simulate macOS app + privacyDashboardController.userScript(PrivacyDashboardUserScript(), didSelectReportAction: true) + XCTAssertFalse(delegateMock.didChangeProtectionSwitchCalled) + XCTAssertFalse(delegateMock.didRequestCloseCalled) + } + + // MARK: - handleViewWillDisappear + + func testHandleViewWillDisappearIfEntryPointIsToggleReportShouldRecordToggleDismissal() { + makePrivacyDashboardController(entryPoint: .toggleReport(completionHandler: { _ in })) + privacyDashboardController.handleViewWillDisappear() + XCTAssertFalse(toggleReportingManagerMock.recordPromptCalled) + XCTAssertTrue(toggleReportingManagerMock.recordDismissalCalled) + } + + func testHandleViewWillDisappearIfInToggleReportFlowShouldRecordToggleDismissal() { + makePrivacyDashboardController(entryPoint: .dashboard) + simulateProtectionToggleSwitch(false) + privacyDashboardController.handleViewWillDisappear() + XCTAssertFalse(toggleReportingManagerMock.recordPromptCalled) + XCTAssertTrue(toggleReportingManagerMock.recordDismissalCalled) + } + + func testHandleViewWillDisappearIfEntryPointIsToggleReportShouldCallCompletionHandler() { + func completionHandler(didSendReport: Bool) { + XCTAssertFalse(didSendReport) + } + makePrivacyDashboardController(entryPoint: .toggleReport(completionHandler: completionHandler(didSendReport:))) + privacyDashboardController.handleViewWillDisappear() + } + + func testHandleViewWillDisappearIfInToggleReportFlowShouldCallDidChangeProtectionSwitch() { + makePrivacyDashboardController(entryPoint: .dashboard) + simulateProtectionToggleSwitch(false) + privacyDashboardController.handleViewWillDisappear() + XCTAssertTrue(delegateMock.didChangeProtectionSwitchCalled) + XCTAssertFalse(delegateMock.didRequestCloseCalled) + } + +} diff --git a/Tests/PrivacyDashboardTests/ToggleReportsManagerTests.swift b/Tests/PrivacyDashboardTests/ToggleReportingManagerTests.swift similarity index 69% rename from Tests/PrivacyDashboardTests/ToggleReportsManagerTests.swift rename to Tests/PrivacyDashboardTests/ToggleReportingManagerTests.swift index aab0b5b49..47cfdc3a2 100644 --- a/Tests/PrivacyDashboardTests/ToggleReportsManagerTests.swift +++ b/Tests/PrivacyDashboardTests/ToggleReportingManagerTests.swift @@ -1,5 +1,5 @@ // -// ToggleReportsManagerTests.swift +// ToggleReportingManagerTests.swift // // Copyright © 2024 DuckDuckGo. All rights reserved. // @@ -19,7 +19,7 @@ @testable import PrivacyDashboard import XCTest -final class MockToggleReportsFeature: ToggleReporting { +final class MockToggleReportingFeature: ToggleReporting { var isEnabled: Bool = true var isDismissLogicEnabled: Bool = true @@ -30,7 +30,7 @@ final class MockToggleReportsFeature: ToggleReporting { } -final class MockToggleReportsStore: ToggleReportsStoring { +final class MockToggleReportingStore: ToggleReportingStoring { var dismissedAt: Date? var promptWindowStart: Date? @@ -38,18 +38,18 @@ final class MockToggleReportsStore: ToggleReportsStoring { } -final class ToggleReportsManagerTests: XCTestCase { +final class ToggleReportingManagerTests: XCTestCase { // MARK: - Dismissal logic func testShouldShowToggleReportWhenNoDismissedDate() { - let manager = ToggleReportsManager(feature: MockToggleReportsFeature(), store: MockToggleReportsStore()) + let manager = ToggleReportingManager(feature: MockToggleReportingFeature(), store: MockToggleReportingStore()) XCTAssertTrue(manager.shouldShowToggleReport) } func testRecordDismissal() { - let store = MockToggleReportsStore() - var manager = ToggleReportsManager(feature: MockToggleReportsFeature(), store: store) + let store = MockToggleReportingStore() + var manager = ToggleReportingManager(feature: MockToggleReportingFeature(), store: store) let now = Date() manager.recordDismissal(date: now) @@ -57,8 +57,8 @@ final class ToggleReportsManagerTests: XCTestCase { } func testShouldShowToggleReportWhenDismissedDateIsMoreThan48HoursAgo() { - let store = MockToggleReportsStore() - let manager = ToggleReportsManager(feature: MockToggleReportsFeature(), store: store) + let store = MockToggleReportingStore() + let manager = ToggleReportingManager(feature: MockToggleReportingFeature(), store: store) let pastDate = Date(timeIntervalSinceNow: -49 * 60 * 60) // 49 hours ago store.dismissedAt = pastDate @@ -66,8 +66,8 @@ final class ToggleReportsManagerTests: XCTestCase { } func testShouldNotShowToggleReportWhenDismissedDateIsLessThan48HoursAgo() { - let store = MockToggleReportsStore() - let manager = ToggleReportsManager(feature: MockToggleReportsFeature(), store: store) + let store = MockToggleReportingStore() + let manager = ToggleReportingManager(feature: MockToggleReportingFeature(), store: store) let recentDate = Date(timeIntervalSinceNow: -47 * 60 * 60) // 47 hours ago store.dismissedAt = recentDate @@ -77,27 +77,27 @@ final class ToggleReportsManagerTests: XCTestCase { // MARK: - Prompt logic func testShouldShowToggleReportWhenPromptLimitNotReached() { - let store = MockToggleReportsStore() + let store = MockToggleReportingStore() store.promptCount = 2 - let manager = ToggleReportsManager(feature: MockToggleReportsFeature(), store: store) + let manager = ToggleReportingManager(feature: MockToggleReportingFeature(), store: store) XCTAssertTrue(manager.shouldShowToggleReport) } func testShouldNotShowToggleReportWhenPromptLimitReachedAndPromptIntervalIsLessThan48HoursAgo() { - let store = MockToggleReportsStore() + let store = MockToggleReportingStore() store.promptCount = 3 store.promptWindowStart = Date().addingTimeInterval(-24 * 60 * 60) - let manager = ToggleReportsManager(feature: MockToggleReportsFeature(), store: store) + let manager = ToggleReportingManager(feature: MockToggleReportingFeature(), store: store) XCTAssertFalse(manager.shouldShowToggleReport) } func testShouldShowToggleReportWhenPromptLimitReachedButPromptIntervalIsMoreThan48HoursAgo() { - let store = MockToggleReportsStore() + let store = MockToggleReportingStore() store.promptCount = 3 store.promptWindowStart = Date().addingTimeInterval(-72 * 60 * 60) - let manager = ToggleReportsManager(feature: MockToggleReportsFeature(), store: store) + let manager = ToggleReportingManager(feature: MockToggleReportingFeature(), store: store) XCTAssertTrue(manager.shouldShowToggleReport) } @@ -105,13 +105,13 @@ final class ToggleReportsManagerTests: XCTestCase { // MARK: - Rolling window func testRecordPromptWhenWithinWindowShouldIncrementCount() { - let store = MockToggleReportsStore() + let store = MockToggleReportingStore() // 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) + var manager = ToggleReportingManager(feature: MockToggleReportingFeature(), store: store) // Record another prompt within the same window manager.recordPrompt(date: Date()) @@ -120,12 +120,12 @@ final class ToggleReportsManagerTests: XCTestCase { } func testRecordPromptWhenOutsideWindowShouldResetCount() { - let store = MockToggleReportsStore() + let store = MockToggleReportingStore() // 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) + var manager = ToggleReportingManager(feature: MockToggleReportingFeature(), store: store) // Record prompt outside the previous window let now = Date() manager.recordPrompt(date: now) @@ -136,12 +136,12 @@ final class ToggleReportsManagerTests: XCTestCase { } func testRecordPromptWhenNoWindowShouldStartNewWindow() { - let store = MockToggleReportsStore() + let store = MockToggleReportingStore() // No initial window start store.promptWindowStart = nil store.promptCount = 0 - var manager = ToggleReportsManager(feature: MockToggleReportsFeature(), store: store) + var manager = ToggleReportingManager(feature: MockToggleReportingFeature(), store: store) // Record prompt without previous window let now = Date() manager.recordPrompt(date: now) @@ -154,38 +154,38 @@ final class ToggleReportsManagerTests: XCTestCase { // MARK: - Combination of both prompts and dismissal logic func testShouldNotShowToggleReportWhenDismissedLessThan48HoursAndPromptLimitNotReached() { - let store = MockToggleReportsStore() + let store = MockToggleReportingStore() store.dismissedAt = Date().addingTimeInterval(-24 * 60 * 60) store.promptCount = 2 - let manager = ToggleReportsManager(feature: MockToggleReportsFeature(), store: store) + let manager = ToggleReportingManager(feature: MockToggleReportingFeature(), store: store) XCTAssertFalse(manager.shouldShowToggleReport(date: Date())) } func testShouldNotShowToggleReportWhenDismissedLessThan48HoursAndPromptLimitReached() { - let store = MockToggleReportsStore() + let store = MockToggleReportingStore() store.dismissedAt = Date().addingTimeInterval(-24 * 60 * 60) store.promptCount = 3 - let manager = ToggleReportsManager(feature: MockToggleReportsFeature(), store: store) + let manager = ToggleReportingManager(feature: MockToggleReportingFeature(), store: store) XCTAssertFalse(manager.shouldShowToggleReport(date: Date())) } func testShouldNotShowToggleReportWhenDismissedMoreThan48HoursAndPromptLimitReached() { - let store = MockToggleReportsStore() + let store = MockToggleReportingStore() store.dismissedAt = Date().addingTimeInterval(-72 * 60 * 60) store.promptWindowStart = Date().addingTimeInterval(-24 * 60 * 60) store.promptCount = 3 - let manager = ToggleReportsManager(feature: MockToggleReportsFeature(), store: store) + let manager = ToggleReportingManager(feature: MockToggleReportingFeature(), store: store) XCTAssertFalse(manager.shouldShowToggleReport(date: Date())) } func testShouldShowToggleReportWhenDismissedMoreThan48HoursAndPromptLimitNotReached() { - let store = MockToggleReportsStore() + let store = MockToggleReportingStore() store.dismissedAt = Date().addingTimeInterval(-72 * 60 * 60) store.promptCount = 2 - let manager = ToggleReportsManager(feature: MockToggleReportsFeature(), store: store) + let manager = ToggleReportingManager(feature: MockToggleReportingFeature(), store: store) XCTAssertTrue(manager.shouldShowToggleReport(date: Date())) } @@ -193,28 +193,28 @@ final class ToggleReportsManagerTests: XCTestCase { // MARK: - Feature func testShouldNotShowToggleReportWhenFeatureDisabled() { - let feature = MockToggleReportsFeature() + let feature = MockToggleReportingFeature() feature.isEnabled = false - let manager = ToggleReportsManager(feature: feature, store: MockToggleReportsStore()) + let manager = ToggleReportingManager(feature: feature, store: MockToggleReportingStore()) XCTAssertFalse(manager.shouldShowToggleReport) } func testShouldShowToggleReportWhenPromptLimitReachedButPromptLimitLogicDisabled() { - let store = MockToggleReportsStore() + let store = MockToggleReportingStore() store.promptWindowStart = Date().addingTimeInterval(-24 * 60 * 60) store.promptCount = 5 - let feature = MockToggleReportsFeature() - let manager = ToggleReportsManager(feature: feature, store: store) + let feature = MockToggleReportingFeature() + let manager = ToggleReportingManager(feature: feature, store: store) XCTAssertFalse(manager.shouldShowToggleReport) feature.isPromptLimitLogicEnabled = false XCTAssertTrue(manager.shouldShowToggleReport) } func testShouldShowToggleReportWhenDismissedDateIsLessThan48HoursAgoButDismissLogicDisabled() { - let store = MockToggleReportsStore() + let store = MockToggleReportingStore() store.dismissedAt = Date().addingTimeInterval(-47 * 60 * 60) - let feature = MockToggleReportsFeature() - let manager = ToggleReportsManager(feature: feature, store: store) + let feature = MockToggleReportingFeature() + let manager = ToggleReportingManager(feature: feature, store: store) XCTAssertFalse(manager.shouldShowToggleReport) feature.isDismissLogicEnabled = false XCTAssertTrue(manager.shouldShowToggleReport) From 5954412504b0cf294f5c0d90d7a0c8dfcd009558 Mon Sep 17 00:00:00 2001 From: Graeme Arthur Date: Fri, 5 Jul 2024 17:52:42 +0200 Subject: [PATCH 6/7] De-duplicate passwords on import (#869) * Update account hashValue with new dedupe logic * Expose password encryption function * Revert "Update account hashValue with new dedupe logic" This reverts commit b21c3794294f06099e23f79dd0826e3850b6436e. * Add deduplicate subfeature * Swiftlint --- .../xcschemes/BrowserServicesKit-Package.xcscheme | 2 +- .../xcshareddata/xcschemes/SubscriptionTests.xcscheme | 9 +-------- .../PrivacyConfig/Features/PrivacyFeature.swift | 1 + .../SecureVault/AutofillSecureVault.swift | 10 +++++++--- 4 files changed, 10 insertions(+), 12 deletions(-) diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/BrowserServicesKit-Package.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/BrowserServicesKit-Package.xcscheme index d3085d9c5..f4c568abd 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/BrowserServicesKit-Package.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/BrowserServicesKit-Package.xcscheme @@ -1,7 +1,7 @@ + version = "1.8"> + version = "1.8"> - - - - diff --git a/Sources/BrowserServicesKit/PrivacyConfig/Features/PrivacyFeature.swift b/Sources/BrowserServicesKit/PrivacyConfig/Features/PrivacyFeature.swift index 21c42faec..34445414d 100644 --- a/Sources/BrowserServicesKit/PrivacyConfig/Features/PrivacyFeature.swift +++ b/Sources/BrowserServicesKit/PrivacyConfig/Features/PrivacyFeature.swift @@ -70,6 +70,7 @@ public enum AutofillSubfeature: String, PrivacySubfeature { case accessCredentialManagement case autofillPasswordGeneration case onByDefault + case deduplicateLoginsOnImport } public enum DBPSubfeature: String, Equatable, PrivacySubfeature { diff --git a/Sources/BrowserServicesKit/SecureVault/AutofillSecureVault.swift b/Sources/BrowserServicesKit/SecureVault/AutofillSecureVault.swift index 0aaa71196..348aabf83 100644 --- a/Sources/BrowserServicesKit/SecureVault/AutofillSecureVault.swift +++ b/Sources/BrowserServicesKit/SecureVault/AutofillSecureVault.swift @@ -118,6 +118,10 @@ public protocol AutofillSecureVault: SecureVault { hashedUsing salt: Data? ) throws + func encryptPassword(for credentials: SecureVaultModels.WebsiteCredentials, + key l2Key: Data?, + salt: Data?) throws -> SecureVaultModels.WebsiteCredentials + func syncableCredentialsForSyncIds(_ syncIds: any Sequence, in database: Database) throws -> [SecureVaultModels.SyncableCredentials] func syncableCredentialsForAccountId(_ accountId: Int64, in database: Database) throws -> SecureVaultModels.SyncableCredentials? } @@ -369,9 +373,9 @@ public class DefaultAutofillSecureVault: AutofillSe try providers.database.storeSyncableCredentials(syncableCredentialsToStore, in: database) } - private func encryptPassword(for credentials: SecureVaultModels.WebsiteCredentials, - key l2Key: Data? = nil, - salt: Data? = nil) throws -> SecureVaultModels.WebsiteCredentials { + public func encryptPassword(for credentials: SecureVaultModels.WebsiteCredentials, + key l2Key: Data? = nil, + salt: Data? = nil) throws -> SecureVaultModels.WebsiteCredentials { do { if let password = credentials.password, String(bytes: password, encoding: .utf8) == nil { assertionFailure("Encrypted password passed to \(#function)") From 0746af01b77d39a1e037bea93b46591534a13b5c Mon Sep 17 00:00:00 2001 From: Diego Rey Mendez Date: Sat, 6 Jul 2024 14:00:07 +0200 Subject: [PATCH 7/7] Add connection tester failure pixels (#881) Task/Issue URL: https://app.asana.com/0/1206580121312550/1207743877093953/f iOS PR: https://github.com/duckduckgo/iOS/pull/3049 macOS PR: https://github.com/duckduckgo/macos-browser/pull/2948 What kind of version bump will this require?: Patch ## Description Adds pixels to track connection tester failures and recovery. These should give us a better idea about how users are faring. --- .../NetworkProtectionConnectionTester.swift | 5 +-- .../PacketTunnelProvider.swift | 40 ++++++++++++++++++- Sources/PixelKit/PixelKit+Parameters.swift | 1 + 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/Sources/NetworkProtection/Diagnostics/NetworkProtectionConnectionTester.swift b/Sources/NetworkProtection/Diagnostics/NetworkProtectionConnectionTester.swift index ec09ba5ba..98653e1c8 100644 --- a/Sources/NetworkProtection/Diagnostics/NetworkProtectionConnectionTester.swift +++ b/Sources/NetworkProtection/Diagnostics/NetworkProtectionConnectionTester.swift @@ -32,7 +32,7 @@ import Common final class NetworkProtectionConnectionTester { enum Result { case connected - case reconnected + case reconnected(failureCount: Int) case disconnected(failureCount: Int) } @@ -267,9 +267,8 @@ final class NetworkProtectionConnectionTester { if failureCount == 0 { resultHandler(.connected) } else if failureCount > 0 { + resultHandler(.reconnected(failureCount: failureCount)) failureCount = 0 - - resultHandler(.reconnected) } } diff --git a/Sources/NetworkProtection/PacketTunnelProvider.swift b/Sources/NetworkProtection/PacketTunnelProvider.swift index 730c044e7..1111d62f5 100644 --- a/Sources/NetworkProtection/PacketTunnelProvider.swift +++ b/Sources/NetworkProtection/PacketTunnelProvider.swift @@ -32,6 +32,7 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { public enum Event { case userBecameActive + case connectionTesterStatusChange(_ status: ConnectionTesterStatus, server: String) case reportConnectionAttempt(attempt: ConnectionAttempt) case tunnelStartAttempt(_ step: TunnelStartAttemptStep) case tunnelStopAttempt(_ step: TunnelStopAttemptStep) @@ -64,6 +65,16 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { case failure } + public enum ConnectionTesterStatus { + case failed(duration: Duration) + case recovered(duration: Duration, failureCount: Int) + + public enum Duration: String { + case immediate + case extended + } + } + // MARK: - Error Handling public enum TunnelError: LocalizedError, CustomNSError, SilentErrorConvertible { @@ -309,6 +320,7 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { // MARK: - Connection tester + private static let connectionTesterExtendedFailuresCount = 8 private var isConnectionTesterEnabled: Bool = true @MainActor @@ -316,16 +328,42 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { NetworkProtectionConnectionTester(timerQueue: timerQueue, log: .networkProtectionConnectionTesterLog) { @MainActor [weak self] result in guard let self else { return } + let serverName = lastSelectedServerInfo?.name ?? "Unknown" + switch result { case .connected: self.tunnelHealth.isHavingConnectivityIssues = false self.updateBandwidthAnalyzerAndRekeyIfExpired() - case .reconnected: + case .reconnected(let failureCount): + providerEvents.fire( + .connectionTesterStatusChange( + .recovered(duration: .immediate, failureCount: failureCount), + server: serverName)) + + if failureCount >= Self.connectionTesterExtendedFailuresCount { + providerEvents.fire( + .connectionTesterStatusChange( + .recovered(duration: .extended, failureCount: failureCount), + server: serverName)) + } + self.tunnelHealth.isHavingConnectivityIssues = false self.updateBandwidthAnalyzerAndRekeyIfExpired() case .disconnected(let failureCount): + if failureCount == 1 { + providerEvents.fire( + .connectionTesterStatusChange( + .failed(duration: .immediate), + server: serverName)) + } else if failureCount == 8 { + providerEvents.fire( + .connectionTesterStatusChange( + .failed(duration: .extended), + server: serverName)) + } + self.tunnelHealth.isHavingConnectivityIssues = true self.bandwidthAnalyzer.reset() } diff --git a/Sources/PixelKit/PixelKit+Parameters.swift b/Sources/PixelKit/PixelKit+Parameters.swift index b69227170..377e56c33 100644 --- a/Sources/PixelKit/PixelKit+Parameters.swift +++ b/Sources/PixelKit/PixelKit+Parameters.swift @@ -21,6 +21,7 @@ import Foundation public extension PixelKit { enum Parameters: Hashable { + public static let count = "count" public static let duration = "duration" public static let test = "test" public static let appVersion = "appVersion"