diff --git a/Core/FeatureFlag.swift b/Core/FeatureFlag.swift index 3a63769bb9..b877485851 100644 --- a/Core/FeatureFlag.swift +++ b/Core/FeatureFlag.swift @@ -45,6 +45,7 @@ public enum FeatureFlag: String { case onboardingAddToDock case autofillSurveys case autcompleteTabs + case adAttributionReporting /// https://app.asana.com/0/72649045549333/1208231259093710/f case networkProtectionUserTips @@ -103,6 +104,8 @@ extension FeatureFlag: FeatureFlagSourceProviding { return .remoteReleasable(.feature(.autocompleteTabs)) case .networkProtectionUserTips: return .remoteReleasable(.subfeature(NetworkProtectionSubfeature.userTips)) + case .adAttributionReporting: + return .remoteReleasable(.feature(.adAttributionReporting)) } } } diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 187e92d283..4a4cbaa73f 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -10970,7 +10970,7 @@ repositoryURL = "https://github.com/DuckDuckGo/BrowserServicesKit"; requirement = { kind = exactVersion; - version = 203.0.0; + version = 203.1.0; }; }; 9F8FE9472BAE50E50071E372 /* XCRemoteSwiftPackageReference "lottie-spm" */ = { diff --git a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 80a835b202..0be6bd842d 100644 --- a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/DuckDuckGo/BrowserServicesKit", "state" : { - "revision" : "45261df2963fc89094e169f9f2d0d9aa098093f3", - "version" : "203.0.0" + "revision" : "19f1e5c945aa92562ad2d087e8d6c99801edf656", + "version" : "203.1.0" } }, { diff --git a/DuckDuckGo/AdAttribution/AdAttributionPixelReporter.swift b/DuckDuckGo/AdAttribution/AdAttributionPixelReporter.swift index a09eb9d693..c5c5f8a3cd 100644 --- a/DuckDuckGo/AdAttribution/AdAttributionPixelReporter.swift +++ b/DuckDuckGo/AdAttribution/AdAttributionPixelReporter.swift @@ -19,28 +19,37 @@ import Foundation import Core +import BrowserServicesKit final actor AdAttributionPixelReporter { - - static let isAdAttributionReportingEnabled = false - + static var shared = AdAttributionPixelReporter() private var fetcherStorage: AdAttributionReporterStorage private let attributionFetcher: AdAttributionFetcher + private let featureFlagger: FeatureFlagger + private let privacyConfigurationManager: PrivacyConfigurationManaging private let pixelFiring: PixelFiringAsync.Type private var isSendingAttribution: Bool = false init(fetcherStorage: AdAttributionReporterStorage = UserDefaultsAdAttributionReporterStorage(), attributionFetcher: AdAttributionFetcher = DefaultAdAttributionFetcher(), + featureFlagger: FeatureFlagger = AppDependencyProvider.shared.featureFlagger, + privacyConfigurationManager: PrivacyConfigurationManaging = ContentBlocking.shared.privacyConfigurationManager, pixelFiring: PixelFiringAsync.Type = Pixel.self) { self.fetcherStorage = fetcherStorage self.attributionFetcher = attributionFetcher self.pixelFiring = pixelFiring + self.featureFlagger = featureFlagger + self.privacyConfigurationManager = privacyConfigurationManager } @discardableResult func reportAttributionIfNeeded() async -> Bool { + guard featureFlagger.isFeatureOn(.adAttributionReporting) else { + return false + } + guard await fetcherStorage.wasAttributionReportSuccessful == false else { return false } @@ -57,7 +66,8 @@ final actor AdAttributionPixelReporter { if let (token, attributionData) = await self.attributionFetcher.fetch() { if attributionData.attribution { - let parameters = self.pixelParametersForAttribution(attributionData, attributionToken: token) + let settings = AdAttributionReporterSettings(privacyConfigurationManager.privacyConfig) + let parameters = self.pixelParametersForAttribution(attributionData, attributionToken: settings.includeToken ? token : nil) do { try await pixelFiring.fire( pixel: .appleAdAttribution, @@ -77,7 +87,7 @@ final actor AdAttributionPixelReporter { return false } - private func pixelParametersForAttribution(_ attribution: AdServicesAttributionResponse, attributionToken: String) -> [String: String] { + private func pixelParametersForAttribution(_ attribution: AdServicesAttributionResponse, attributionToken: String?) -> [String: String] { var params: [String: String] = [:] params[PixelParameters.adAttributionAdGroupID] = attribution.adGroupId.map(String.init) @@ -93,3 +103,17 @@ final actor AdAttributionPixelReporter { return params } } + +private struct AdAttributionReporterSettings { + var includeToken: Bool + + init(_ configuration: PrivacyConfiguration) { + let featureSettings = configuration.settings(for: .adAttributionReporting) + + self.includeToken = featureSettings[Key.includeToken] as? Bool ?? false + } + + private enum Key { + static let includeToken = "includeToken" + } +} diff --git a/DuckDuckGo/AppDelegate.swift b/DuckDuckGo/AppDelegate.swift index b5448d7b67..a585a0cbee 100644 --- a/DuckDuckGo/AppDelegate.swift +++ b/DuckDuckGo/AppDelegate.swift @@ -540,8 +540,6 @@ import os.log } private func reportAdAttribution() { - guard AdAttributionPixelReporter.isAdAttributionReportingEnabled else { return } - Task.detached(priority: .background) { await AdAttributionPixelReporter.shared.reportAttributionIfNeeded() } diff --git a/DuckDuckGoTests/AdAttributionPixelReporterTests.swift b/DuckDuckGoTests/AdAttributionPixelReporterTests.swift index 0a553d07b0..f8846346bd 100644 --- a/DuckDuckGoTests/AdAttributionPixelReporterTests.swift +++ b/DuckDuckGoTests/AdAttributionPixelReporterTests.swift @@ -26,15 +26,24 @@ final class AdAttributionPixelReporterTests: XCTestCase { private var attributionFetcher: AdAttributionFetcherMock! private var fetcherStorage: AdAttributionReporterStorageMock! + private var featureFlagger: MockFeatureFlagger! + private var privacyConfigurationManager: PrivacyConfigurationManagerMock! override func setUpWithError() throws { attributionFetcher = AdAttributionFetcherMock() fetcherStorage = AdAttributionReporterStorageMock() + featureFlagger = MockFeatureFlagger() + privacyConfigurationManager = PrivacyConfigurationManagerMock() + + featureFlagger.enabledFeatureFlags.append(.adAttributionReporting) } override func tearDownWithError() throws { attributionFetcher = nil fetcherStorage = nil + featureFlagger = nil + privacyConfigurationManager = nil + PixelFiringMock.tearDown() } @@ -59,7 +68,7 @@ final class AdAttributionPixelReporterTests: XCTestCase { XCTAssertFalse(result) } - func testPixelname() async { + func testPixelName() async { let sut = createSUT() attributionFetcher.fetchResponse = ("example", AdServicesAttributionResponse(attribution: true)) @@ -72,6 +81,7 @@ final class AdAttributionPixelReporterTests: XCTestCase { func testPixelAttributesNaming() async throws { let sut = createSUT() attributionFetcher.fetchResponse = ("example", AdServicesAttributionResponse(attribution: true)) + (privacyConfigurationManager.privacyConfig as? PrivacyConfigurationMock)?.settings[.adAttributionReporting] = ["includeToken": true] await sut.reportAttributionIfNeeded() @@ -157,9 +167,50 @@ final class AdAttributionPixelReporterTests: XCTestCase { XCTAssertFalse(result) } + func testDoesNotReportIfFeatureDisabled() async { + let sut = createSUT() + attributionFetcher.fetchResponse = ("example", AdServicesAttributionResponse(attribution: true)) + featureFlagger.enabledFeatureFlags = [] + + await fetcherStorage.markAttributionReportSuccessful() + let result = await sut.reportAttributionIfNeeded() + + XCTAssertNil(PixelFiringMock.lastPixelName) + XCTAssertFalse(result) + XCTAssertFalse(attributionFetcher.wasFetchCalled) + } + + func testDoesNotIncludeTokenWhenSettingMissing() async throws { + let sut = createSUT() + attributionFetcher.fetchResponse = ("example", AdServicesAttributionResponse(attribution: true)) + featureFlagger.enabledFeatureFlags = [.adAttributionReporting] + + await sut.reportAttributionIfNeeded() + + let pixelAttributes = try XCTUnwrap(PixelFiringMock.lastParams) + + XCTAssertNil(pixelAttributes["attribution_token"]) + } + + func testIncludesTokenWhenSettingEnabled() async throws { + let sut = createSUT() + attributionFetcher.fetchResponse = ("example", AdServicesAttributionResponse(attribution: true)) + featureFlagger.enabledFeatureFlags = [.adAttributionReporting] + + (privacyConfigurationManager.privacyConfig as? PrivacyConfigurationMock)?.settings[.adAttributionReporting] = ["includeToken": true] + + await sut.reportAttributionIfNeeded() + + let pixelAttributes = try XCTUnwrap(PixelFiringMock.lastParams) + + XCTAssertNotNil(pixelAttributes["attribution_token"]) + } + private func createSUT() -> AdAttributionPixelReporter { AdAttributionPixelReporter(fetcherStorage: fetcherStorage, attributionFetcher: attributionFetcher, + featureFlagger: featureFlagger, + privacyConfigurationManager: privacyConfigurationManager, pixelFiring: PixelFiringMock.self) } } @@ -173,9 +224,12 @@ class AdAttributionReporterStorageMock: AdAttributionReporterStorage { } class AdAttributionFetcherMock: AdAttributionFetcher { + var wasFetchCalled: Bool = false + var fetchResponse: (String, AdServicesAttributionResponse)? func fetch() async -> (String, AdServicesAttributionResponse)? { - fetchResponse + wasFetchCalled = true + return fetchResponse } }