Skip to content

Commit

Permalink
Add Privacy Config feature to control ad attribution reporting (#3506)
Browse files Browse the repository at this point in the history
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)
  • Loading branch information
dus7 authored Nov 4, 2024
1 parent 4390003 commit 556b858
Show file tree
Hide file tree
Showing 6 changed files with 91 additions and 12 deletions.
3 changes: 3 additions & 0 deletions Core/FeatureFlag.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -103,6 +104,8 @@ extension FeatureFlag: FeatureFlagSourceProviding {
return .remoteReleasable(.feature(.autocompleteTabs))
case .networkProtectionUserTips:
return .remoteReleasable(.subfeature(NetworkProtectionSubfeature.userTips))
case .adAttributionReporting:
return .remoteReleasable(.feature(.adAttributionReporting))
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion DuckDuckGo.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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" */ = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
},
{
Expand Down
34 changes: 29 additions & 5 deletions DuckDuckGo/AdAttribution/AdAttributionPixelReporter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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,
Expand All @@ -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)
Expand All @@ -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"
}
}
2 changes: 0 additions & 2 deletions DuckDuckGo/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -540,8 +540,6 @@ import os.log
}

private func reportAdAttribution() {
guard AdAttributionPixelReporter.isAdAttributionReportingEnabled else { return }

Task.detached(priority: .background) {
await AdAttributionPixelReporter.shared.reportAttributionIfNeeded()
}
Expand Down
58 changes: 56 additions & 2 deletions DuckDuckGoTests/AdAttributionPixelReporterTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand All @@ -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))

Expand All @@ -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()

Expand Down Expand Up @@ -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)
}
}
Expand All @@ -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
}
}

Expand Down

0 comments on commit 556b858

Please sign in to comment.