From 556b858a0b0c07c152183b06c5dd52d1262ed38e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mariusz=20=C5=9Apiewak?= Date: Mon, 4 Nov 2024 11:49:32 +0100 Subject: [PATCH] Add Privacy Config feature to control ad attribution reporting (#3506) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task/Issue URL: https://app.asana.com/0/0/1208638248015576/f Tech Design URL: CC: **Description**: Adds the ability to use remote config to control `AdAttributionPixelReporter` and whether the token is added as parameter. **Steps to test this PR**: ⚠️ Device is required to fully test this change. Attribution is not available on simulator. 1. Modify remote config URL to `https://www.jsonblob.com/api/1301173210350215168`. Put app in the background and reactivate. 2. Verify attribution pixel is fired including token parameter. 4. Remove the app, change `includeToken` setting to `false` in the linked configuration json file or remove setting object completely, verify attribution pixel is fired without token parameter. 5. Turn off the feature in configuration json, verify no attribution pixel is fired. **Definition of Done (Internal Only)**: * [ ] Does this PR satisfy our [Definition of Done](https://app.asana.com/0/1202500774821704/1207634633537039/f)? **Copy Testing**: * [ ] Use of correct apostrophes in new copy, ie `’` rather than `'` **Orientation Testing**: * [ ] Portrait * [ ] Landscape **Device Testing**: * [ ] iPhone SE (1st Gen) * [ ] iPhone 8 * [ ] iPhone X * [ ] iPhone 14 Pro * [ ] iPad **OS Testing**: * [ ] iOS 15 * [ ] iOS 16 * [ ] iOS 17 **Theme Testing**: * [ ] Light theme * [ ] Dark theme --- ###### Internal references: [Software Engineering Expectations](https://app.asana.com/0/59792373528535/199064865822552) [Technical Design Template](https://app.asana.com/0/59792373528535/184709971311943) --- Core/FeatureFlag.swift | 3 + DuckDuckGo.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- .../AdAttributionPixelReporter.swift | 34 +++++++++-- DuckDuckGo/AppDelegate.swift | 2 - .../AdAttributionPixelReporterTests.swift | 58 ++++++++++++++++++- 6 files changed, 91 insertions(+), 12 deletions(-) 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 } }