From 62c54ab2d2c6a3d7f1bdbe53edf2cd638f256953 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mariusz=20=C5=9Apiewak?= Date: Fri, 1 Mar 2024 17:04:05 +0100 Subject: [PATCH] Report Apple Ad attribution using pixel (#2510) --- Core/Pixel.swift | 9 + Core/PixelEvent.swift | 5 + Core/UserDefaultsPropertyWrapper.swift | 3 +- DuckDuckGo.xcodeproj/project.pbxproj | 36 +++ .../AdAttribution/AdAttributionFetcher.swift | 159 ++++++++++++++ .../AdAttributionPixelReporter.swift | 96 ++++++++ .../AdAttributionReporterStorage.swift | 38 ++++ DuckDuckGo/AppDelegate.swift | 8 + .../AdAttributionFetcherTests.swift | 183 ++++++++++++++++ .../AdAttributionPixelReporterTests.swift | 206 ++++++++++++++++++ 10 files changed, 742 insertions(+), 1 deletion(-) create mode 100644 DuckDuckGo/AdAttribution/AdAttributionFetcher.swift create mode 100644 DuckDuckGo/AdAttribution/AdAttributionPixelReporter.swift create mode 100644 DuckDuckGo/AdAttribution/AdAttributionReporterStorage.swift create mode 100644 DuckDuckGoTests/AdAttributionFetcherTests.swift create mode 100644 DuckDuckGoTests/AdAttributionPixelReporterTests.swift diff --git a/Core/Pixel.swift b/Core/Pixel.swift index 155ced7fae..dd0a8e77a6 100644 --- a/Core/Pixel.swift +++ b/Core/Pixel.swift @@ -127,6 +127,15 @@ public struct PixelParameters { public static let returnUserErrorCode = "error_code" public static let returnUserOldATB = "old_atb" public static let returnUserNewATB = "new_atb" + + // Ad Attribution + public static let adAttributionOrgID = "org_id" + public static let adAttributionCampaignID = "campaign_id" + public static let adAttributionConversionType = "conversion_type" + public static let adAttributionAdGroupID = "ad_group_id" + public static let adAttributionCountryOrRegion = "country_or_region" + public static let adAttributionKeywordID = "keyword_id" + public static let adAttributionAdID = "ad_id" } public struct PixelValues { diff --git a/Core/PixelEvent.swift b/Core/PixelEvent.swift index 291db3d335..527ce9c495 100644 --- a/Core/PixelEvent.swift +++ b/Core/PixelEvent.swift @@ -527,6 +527,8 @@ extension Pixel { case appRatingPromptFetchError + case appleAdAttribution + case userBehaviorReloadTwice case userBehaviorReloadAndRestart case userBehaviorReloadAndFireButton @@ -1032,6 +1034,9 @@ extension Pixel.Event { case .debugReturnUserUpdateATB: return "m_debug_return_user_update_atb" case .appRatingPromptFetchError: return "m_d_app_rating_prompt_fetch_error" + + // MARK: - Apple Ad Attribution + case .appleAdAttribution: return "m_apple-ad-attribution" // MARK: - User behavior case .userBehaviorReloadTwice: return "m_reload-twice" diff --git a/Core/UserDefaultsPropertyWrapper.swift b/Core/UserDefaultsPropertyWrapper.swift index 9ab2131af7..f0515137b2 100644 --- a/Core/UserDefaultsPropertyWrapper.swift +++ b/Core/UserDefaultsPropertyWrapper.swift @@ -124,9 +124,10 @@ public struct UserDefaultsWrapper { case subscriptionIsActive = "com.duckduckgo.ios.subscruption.isActive" + case appleAdAttributionReportCompleted = "com.duckduckgo.ios.appleAdAttributionReport.completed" + case didRefreshTimestamp = "com.duckduckgo.ios.userBehavior.didRefreshTimestamp" case didBurnTimestamp = "com.duckduckgo.ios.userBehavior.didBurnTimestamp" - } private let key: Key diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 3de5ac902f..c94784731f 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -309,7 +309,12 @@ 4BFB911B29B7D9530014D4B7 /* AppTrackingProtectionStoringModelPerformanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BFB911A29B7D9530014D4B7 /* AppTrackingProtectionStoringModelPerformanceTests.swift */; }; 6AC6DAB328804F97002723C0 /* BarsAnimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AC6DAB228804F97002723C0 /* BarsAnimator.swift */; }; 6AC98419288055C1005FA9CA /* BarsAnimatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AC98418288055C1005FA9CA /* BarsAnimatorTests.swift */; }; + 6FD1BAE42B87A107000C475C /* AdAttributionPixelReporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FD1BAE12B87A107000C475C /* AdAttributionPixelReporter.swift */; }; + 6FD1BAE52B87A107000C475C /* AdAttributionReporterStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FD1BAE22B87A107000C475C /* AdAttributionReporterStorage.swift */; }; + 6FD1BAE62B87A107000C475C /* AdAttributionFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FD1BAE32B87A107000C475C /* AdAttributionFetcher.swift */; }; + 6FD3AEE32B8F4EEB0060FCCC /* AdAttributionPixelReporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FD3AEE12B8DFBB80060FCCC /* AdAttributionPixelReporterTests.swift */; }; 6FDA1FB32B59584400AC962A /* AddressDisplayHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FDA1FB22B59584400AC962A /* AddressDisplayHelper.swift */; }; + 6FF915822B88E07A0042AC87 /* AdAttributionFetcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FF915802B88E0750042AC87 /* AdAttributionFetcherTests.swift */; }; 83004E802193BB8200DA013C /* WKNavigationExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83004E7F2193BB8200DA013C /* WKNavigationExtension.swift */; }; 83004E862193E5ED00DA013C /* TabViewControllerBrowsingMenuExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83004E852193E5ED00DA013C /* TabViewControllerBrowsingMenuExtension.swift */; }; 83004E882193E8C700DA013C /* TabViewControllerLongPressMenuExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83004E872193E8C700DA013C /* TabViewControllerLongPressMenuExtension.swift */; }; @@ -1399,7 +1404,12 @@ 6AC6DAB228804F97002723C0 /* BarsAnimator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BarsAnimator.swift; sourceTree = ""; }; 6AC98418288055C1005FA9CA /* BarsAnimatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BarsAnimatorTests.swift; sourceTree = ""; }; 6FB030C7234331B400A10DB9 /* Configuration.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Configuration.xcconfig; path = Configuration/Configuration.xcconfig; sourceTree = ""; }; + 6FD1BAE12B87A107000C475C /* AdAttributionPixelReporter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = AdAttributionPixelReporter.swift; path = AdAttribution/AdAttributionPixelReporter.swift; sourceTree = ""; }; + 6FD1BAE22B87A107000C475C /* AdAttributionReporterStorage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = AdAttributionReporterStorage.swift; path = AdAttribution/AdAttributionReporterStorage.swift; sourceTree = ""; }; + 6FD1BAE32B87A107000C475C /* AdAttributionFetcher.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = AdAttributionFetcher.swift; path = AdAttribution/AdAttributionFetcher.swift; sourceTree = ""; }; + 6FD3AEE12B8DFBB80060FCCC /* AdAttributionPixelReporterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdAttributionPixelReporterTests.swift; sourceTree = ""; }; 6FDA1FB22B59584400AC962A /* AddressDisplayHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressDisplayHelper.swift; sourceTree = ""; }; + 6FF915802B88E0750042AC87 /* AdAttributionFetcherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdAttributionFetcherTests.swift; sourceTree = ""; }; 83004E7F2193BB8200DA013C /* WKNavigationExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WKNavigationExtension.swift; sourceTree = ""; }; 83004E832193E14C00DA013C /* UIAlertControllerExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = UIAlertControllerExtension.swift; path = ../Core/UIAlertControllerExtension.swift; sourceTree = ""; }; 83004E852193E5ED00DA013C /* TabViewControllerBrowsingMenuExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabViewControllerBrowsingMenuExtension.swift; sourceTree = ""; }; @@ -3545,6 +3555,25 @@ name = VPN; sourceTree = ""; }; + 6FD1BAE02B87A0E8000C475C /* AdAttribution */ = { + isa = PBXGroup; + children = ( + 6FD1BAE32B87A107000C475C /* AdAttributionFetcher.swift */, + 6FD1BAE12B87A107000C475C /* AdAttributionPixelReporter.swift */, + 6FD1BAE22B87A107000C475C /* AdAttributionReporterStorage.swift */, + ); + name = AdAttribution; + sourceTree = ""; + }; + 6FF9157F2B88E04F0042AC87 /* AdAttribution */ = { + isa = PBXGroup; + children = ( + 6FF915802B88E0750042AC87 /* AdAttributionFetcherTests.swift */, + 6FD3AEE12B8DFBB80060FCCC /* AdAttributionPixelReporterTests.swift */, + ); + name = AdAttribution; + sourceTree = ""; + }; 830FA79B1F8E81FB00FCE105 /* ContentBlocker */ = { isa = PBXGroup; children = ( @@ -3730,6 +3759,7 @@ CB48D32F2B90CE8500631D8B /* UserBehaviorMonitor */, EE3B98EA2A9634CC002F63A0 /* DuckDuckGoAlpha.entitlements */, CB258D1129A4F1BB00DEBA24 /* Configuration */, + 6FD1BAE02B87A0E8000C475C /* AdAttribution */, 1E908BED29827C480008C8F3 /* Autoconsent */, 3157B43627F4C8380042D3D7 /* Favicons */, AA4D6A8023DE4973007E8790 /* AppIcon */, @@ -4936,6 +4966,7 @@ F12D98401F266B30003C2EE3 /* DuckDuckGo */ = { isa = PBXGroup; children = ( + 6FF9157F2B88E04F0042AC87 /* AdAttribution */, CB48D3342B90CEBD00631D8B /* UserBehaviorMonitor */, F17669A21E411D63003D3222 /* Application */, 026F08B629B7DC130079B9DF /* AppTrackingProtection */, @@ -6485,6 +6516,7 @@ 319A371028299A850079FBCE /* PasswordHider.swift in Sources */, 982C87C42255559A00919035 /* UITableViewCellExtension.swift in Sources */, B623C1C42862CD670043013E /* WKDownloadSession.swift in Sources */, + 6FD1BAE42B87A107000C475C /* AdAttributionPixelReporter.swift in Sources */, EEFD562F2A65B6CA00DAEC48 /* NetworkProtectionInviteViewModel.swift in Sources */, 1E8AD1D927C4FEC100ABA377 /* DownloadsListSectioningHelper.swift in Sources */, 1E4DCF4827B6A35400961E25 /* DownloadsListModel.swift in Sources */, @@ -6661,6 +6693,7 @@ 31CB4251273AF50700FA0F3F /* SpeechRecognizerProtocol.swift in Sources */, 319A37172829C8AD0079FBCE /* UITableViewExtension.swift in Sources */, 85EE7F59224673C5000FE757 /* WebContainerNavigationController.swift in Sources */, + 6FD1BAE52B87A107000C475C /* AdAttributionReporterStorage.swift in Sources */, D68A21462B7EC16200BB372E /* SubscriptionExternalLinkViewModel.swift in Sources */, F4C9FBF528340DDA002281CC /* AutofillInterfaceEmailTruncator.swift in Sources */, 1E016AB42949FEB500F21625 /* OmniBarNotificationViewModel.swift in Sources */, @@ -6881,6 +6914,7 @@ 1E4FAA6627D8DFC800ADC5B3 /* CompleteDownloadRowViewModel.swift in Sources */, D6D95CE12B6D52DA00960317 /* RootPresentationMode.swift in Sources */, 83004E862193E5ED00DA013C /* TabViewControllerBrowsingMenuExtension.swift in Sources */, + 6FD1BAE62B87A107000C475C /* AdAttributionFetcher.swift in Sources */, EE01EB402AFBD0000096AAC9 /* NetworkProtectionVPNSettingsViewModel.swift in Sources */, EE72CA852A862D000043B5B3 /* NetworkProtectionDebugViewController.swift in Sources */, C18ED43A2AB6F77600BF3805 /* AutofillSettingsEnableFooterView.swift in Sources */, @@ -6959,6 +6993,7 @@ CBDD5DDF29A6736A00832877 /* APIHeadersTests.swift in Sources */, 986B45D0299E30A50089D2D7 /* BookmarkEntityTests.swift in Sources */, B6AD9E3828D4512E0019CDE9 /* EmbeddedTrackerDataTests.swift in Sources */, + 6FF915822B88E07A0042AC87 /* AdAttributionFetcherTests.swift in Sources */, 1E722729292EB24D003B5F53 /* AppSettingsMock.swift in Sources */, 8536A1C8209AF2410050739E /* MockVariantManager.swift in Sources */, C1B7B53428944EFA0098FD6A /* CoreDataTestUtilities.swift in Sources */, @@ -7035,6 +7070,7 @@ 9847C00527A41A0A00DB07AA /* WebViewTestHelper.swift in Sources */, 3170048227A9504F00C03F35 /* DownloadMocks.swift in Sources */, 317045C02858C6B90016ED1F /* AutofillInterfaceEmailTruncatorTests.swift in Sources */, + 6FD3AEE32B8F4EEB0060FCCC /* AdAttributionPixelReporterTests.swift in Sources */, 987130C6294AAB9F00AB05E0 /* BookmarkListViewModelTests.swift in Sources */, F1134ED21F40EF3A00B73467 /* JsonTestDataLoader.swift in Sources */, 4B83397129AC18C9003F7EA9 /* AppTrackingProtectionStoringModelTests.swift in Sources */, diff --git a/DuckDuckGo/AdAttribution/AdAttributionFetcher.swift b/DuckDuckGo/AdAttribution/AdAttributionFetcher.swift new file mode 100644 index 0000000000..a9d3d31a80 --- /dev/null +++ b/DuckDuckGo/AdAttribution/AdAttributionFetcher.swift @@ -0,0 +1,159 @@ +// +// AdAttributionFetcher.swift +// DuckDuckGo +// +// 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 AdServices +import Common + +protocol AdAttributionFetcher { + func fetch() async -> AdServicesAttributionResponse? +} + +/// Fetches ad attribution data for from Apple. +/// +/// DuckDuckGo uses the AdServices framework to fetch and monitor anonymous install attribution data from Apple. No personally identifiable data is involved. +/// DuckDuckGo does not use the App Tracking Transparency framework at any point, and only uses the “standard” attribution payload. +/// See https://developer.apple.com/documentation/adservices/aaattribution/attributiontoken()#Attribution-payload-descriptions for details. +struct DefaultAdAttributionFetcher: AdAttributionFetcher { + + typealias TokenGetter = () throws -> String + + private let tokenGetter: TokenGetter + private let urlSession: URLSession + private let retryInterval: TimeInterval + + init(tokenGetter: @escaping TokenGetter = Self.fetchAttributionToken, + urlSession: URLSession = .shared, + retryInterval: TimeInterval = .seconds(5)) { + self.tokenGetter = tokenGetter + self.urlSession = urlSession + self.retryInterval = retryInterval + } + + func fetch() async -> AdServicesAttributionResponse? { + guard #available(iOS 14.3, *) else { + return nil + } + + var lastToken: String? + + for _ in 0.. AdServicesAttributionResponse { + let request = createAttributionDataRequest(with: token) + let (data, response) = try await urlSession.data(for: request) + + guard let response = response as? HTTPURLResponse else { + throw AdAttributionFetcherError.invalidResponse + } + + switch response.statusCode { + case 200: + let decoder = JSONDecoder() + let decoded = try decoder.decode(AdServicesAttributionResponse.self, from: data) + + return decoded + case 400: + throw AdAttributionFetcherError.invalidToken + case 404: + throw AdAttributionFetcherError.invalidResponse + default: + throw AdAttributionFetcherError.unknown + } + } + + private func createAttributionDataRequest(with token: String) -> URLRequest { + var request = URLRequest(url: Constant.attributionServiceURL) + request.httpMethod = "POST" + request.setValue("text/plain", forHTTPHeaderField: "Content-Type") + request.httpBody = token.data(using: .utf8) + + return request + } + + private struct Constant { + static let attributionServiceURL = URL(string: "https://api-adservices.apple.com/api/v1/")! + static let maxRetries = 3 + } +} + +extension AdAttributionFetcher { + static func fetchAttributionToken() throws -> String { + if #available(iOS 14.3, *) { + return try AAAttribution.attributionToken() + } else { + throw AdAttributionFetcherError.attributionUnsupported + } + } +} + +struct AdServicesAttributionResponse: Decodable { + let attribution: Bool + let orgId: Int? + let campaignId: Int? + let conversionType: String? + let adGroupId: Int? + let countryOrRegion: String? + let keywordId: Int? + let adId: Int? +} + +enum AdAttributionFetcherError: Error { + case attributionUnsupported + case invalidResponse + case invalidToken + case unknown + + var allowsRetry: Bool { + switch self { + case .invalidToken, .invalidResponse: + return true + case .unknown, .attributionUnsupported: + return false + } + } +} diff --git a/DuckDuckGo/AdAttribution/AdAttributionPixelReporter.swift b/DuckDuckGo/AdAttribution/AdAttributionPixelReporter.swift new file mode 100644 index 0000000000..4ea0faa969 --- /dev/null +++ b/DuckDuckGo/AdAttribution/AdAttributionPixelReporter.swift @@ -0,0 +1,96 @@ +// +// AdAttributionPixelReporter.swift +// DuckDuckGo +// +// 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 Core + +protocol PixelFiring { + static func fire(pixel: Pixel.Event, withAdditionalParameters params: [String: String]) async throws +} + +final class AdAttributionPixelReporter { + + static var shared = AdAttributionPixelReporter() + + private var fetcherStorage: AdAttributionReporterStorage + private let attributionFetcher: AdAttributionFetcher + private let pixelFiring: PixelFiring.Type + + init(fetcherStorage: AdAttributionReporterStorage = UserDefaultsAdAttributionReporterStorage(), + attributionFetcher: AdAttributionFetcher = DefaultAdAttributionFetcher(), + pixelFiring: PixelFiring.Type = Pixel.self) { + self.fetcherStorage = fetcherStorage + self.attributionFetcher = attributionFetcher + self.pixelFiring = pixelFiring + } + + @discardableResult + func reportAttributionIfNeeded() async -> Bool { + guard await fetcherStorage.wasAttributionReportSuccessful == false else { + return false + } + + if let attributionData = await self.attributionFetcher.fetch() { + if attributionData.attribution { + let parameters = self.pixelParametersForAttribution(attributionData) + do { + try await pixelFiring.fire(pixel: .appleAdAttribution, withAdditionalParameters: parameters) + } catch { + return false + } + } + + await fetcherStorage.markAttributionReportSuccessful() + + return true + } + + return false + } + + private func pixelParametersForAttribution(_ attribution: AdServicesAttributionResponse) -> [String: String] { + var params: [String: String] = [:] + + params[PixelParameters.adAttributionAdGroupID] = attribution.adGroupId.map(String.init) + params[PixelParameters.adAttributionOrgID] = attribution.orgId.map(String.init) + params[PixelParameters.adAttributionCampaignID] = attribution.campaignId.map(String.init) + params[PixelParameters.adAttributionConversionType] = attribution.conversionType + params[PixelParameters.adAttributionAdGroupID] = attribution.adGroupId.map(String.init) + params[PixelParameters.adAttributionCountryOrRegion] = attribution.countryOrRegion + params[PixelParameters.adAttributionKeywordID] = attribution.keywordId.map(String.init) + params[PixelParameters.adAttributionAdID] = attribution.adId.map(String.init) + + return params + } +} + +extension Pixel: PixelFiring { + static func fire(pixel: Event, withAdditionalParameters params: [String: String]) async throws { + + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + Pixel.fire(pixel: pixel, withAdditionalParameters: params) { error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume() + } + } + } + } +} diff --git a/DuckDuckGo/AdAttribution/AdAttributionReporterStorage.swift b/DuckDuckGo/AdAttribution/AdAttributionReporterStorage.swift new file mode 100644 index 0000000000..c0085e05f9 --- /dev/null +++ b/DuckDuckGo/AdAttribution/AdAttributionReporterStorage.swift @@ -0,0 +1,38 @@ +// +// AdAttributionReporterStorage.swift +// DuckDuckGo +// +// 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 Core +import Foundation + +protocol AdAttributionReporterStorage { + var wasAttributionReportSuccessful: Bool { get async } + + func markAttributionReportSuccessful() async +} + +final class UserDefaultsAdAttributionReporterStorage: AdAttributionReporterStorage { + @MainActor + @UserDefaultsWrapper(key: .appleAdAttributionReportCompleted, defaultValue: false) + var wasAttributionReportSuccessful: Bool + + @MainActor + func markAttributionReportSuccessful() async { + wasAttributionReportSuccessful = true + } +} diff --git a/DuckDuckGo/AppDelegate.swift b/DuckDuckGo/AppDelegate.swift index 1f712dd2c6..3bd12868ed 100644 --- a/DuckDuckGo/AppDelegate.swift +++ b/DuckDuckGo/AppDelegate.swift @@ -335,6 +335,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { clearDebugWaitlistState() + reportAdAttribution() + AppDependencyProvider.shared.userBehaviorMonitor.handleAction(.reopenApp) return true @@ -387,6 +389,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } #endif + private func reportAdAttribution() { + Task.detached(priority: .background) { + await AdAttributionPixelReporter.shared.reportAttributionIfNeeded() + } + } + func applicationDidBecomeActive(_ application: UIApplication) { guard !testing else { return } diff --git a/DuckDuckGoTests/AdAttributionFetcherTests.swift b/DuckDuckGoTests/AdAttributionFetcherTests.swift new file mode 100644 index 0000000000..28b9c94541 --- /dev/null +++ b/DuckDuckGoTests/AdAttributionFetcherTests.swift @@ -0,0 +1,183 @@ +// +// AdAttributionFetcherTests.swift +// DuckDuckGo +// +// 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 DuckDuckGo +@testable import TestUtils + +final class AdAttributionFetcherTests: XCTestCase { + + private let mockSession: URLSession = { + let configuration = URLSessionConfiguration.default + configuration.protocolClasses = [MockURLProtocol.self] + let session = URLSession(configuration: configuration) + + return session + }() + + override func setUpWithError() throws { + MockURLProtocol.requestHandler = MockURLProtocol.defaultHandler + } + + override func tearDownWithError() throws { + MockURLProtocol.requestHandler = nil + } + + func testMakesRequestWithToken() async throws { + let testToken = "foo" + let sut = DefaultAdAttributionFetcher(tokenGetter: { testToken }, urlSession: mockSession, retryInterval: .leastNonzeroMagnitude) + + _ = await sut.fetch() + + let requestStream = try XCTUnwrap(MockURLProtocol.lastRequest?.httpBodyStream) + let requestBody = try Data(reading: requestStream) + + XCTAssertEqual(String(data: requestBody, encoding: .utf8), testToken) + } + + func testRetriesRequest() async throws { + let testToken = "foo" + let sut = DefaultAdAttributionFetcher(tokenGetter: { testToken }, urlSession: mockSession, retryInterval: .leastNonzeroMagnitude) + let retryExpectation = XCTestExpectation() + retryExpectation.expectedFulfillmentCount = 3 + retryExpectation.assertForOverFulfill = true + + MockURLProtocol.requestHandler = { request in + retryExpectation.fulfill() + let handler = MockURLProtocol.handler(with: 404) + return try handler(request) + } + + _ = await sut.fetch() + + let requestStream = try XCTUnwrap(MockURLProtocol.lastRequest?.httpBodyStream) + let requestBody = try Data(reading: requestStream) + + XCTAssertEqual(String(data: requestBody, encoding: .utf8), testToken) + + await fulfillment(of: [retryExpectation]) + } + + func testRefreshesTokenOnRetry() async throws { + let retryExpectation = XCTestExpectation() + retryExpectation.expectedFulfillmentCount = 3 + retryExpectation.assertForOverFulfill = true + + let refreshExpectation = XCTestExpectation() + + let testToken = "foo" + let sut = DefaultAdAttributionFetcher(tokenGetter: { + refreshExpectation.fulfill() + return testToken + }, urlSession: mockSession, retryInterval: .leastNonzeroMagnitude) + + MockURLProtocol.requestHandler = { request in + retryExpectation.fulfill() + let handler = MockURLProtocol.handler(with: 400) + return try handler(request) + } + + _ = await sut.fetch() + + let requestStream = try XCTUnwrap(MockURLProtocol.lastRequest?.httpBodyStream) + let requestBody = try Data(reading: requestStream) + + XCTAssertEqual(String(data: requestBody, encoding: .utf8), testToken) + + await fulfillment(of: [retryExpectation]) + } + + func testDoesNotRetry_WhenUnrecoverable() async throws { + let testToken = "foo" + let sut = DefaultAdAttributionFetcher(tokenGetter: { testToken }, urlSession: mockSession, retryInterval: .leastNonzeroMagnitude) + let noRetryExpectation = XCTestExpectation() + noRetryExpectation.expectedFulfillmentCount = 1 + noRetryExpectation.assertForOverFulfill = true + + MockURLProtocol.requestHandler = { request in + noRetryExpectation.fulfill() + let handler = MockURLProtocol.handler(with: 500) + return try handler(request) + } + + _ = await sut.fetch() + + let requestStream = try XCTUnwrap(MockURLProtocol.lastRequest?.httpBodyStream) + let requestBody = try Data(reading: requestStream) + + XCTAssertEqual(String(data: requestBody, encoding: .utf8), testToken) + + await fulfillment(of: [noRetryExpectation]) + } + + func testRespectsRetryInterval() async throws { + let testToken = "foo" + let sut = DefaultAdAttributionFetcher(tokenGetter: { testToken }, urlSession: mockSession, retryInterval: .milliseconds(30)) + + MockURLProtocol.requestHandler = { request in + let handler = MockURLProtocol.handler(with: 404) + return try handler(request) + } + + let startTime = Date() + _ = await sut.fetch() + + XCTAssertGreaterThanOrEqual(Date().timeIntervalSince(startTime), .milliseconds(90)) + } +} + +private extension MockURLProtocol { + typealias RequestHandler = (URLRequest) throws -> (HTTPURLResponse, Data?) + + static func handler(with statusCode: Int, data: Data? = nil) -> RequestHandler { + return { request in + (HTTPURLResponse(url: request.url!, statusCode: statusCode, httpVersion: nil, headerFields: nil)!, data) + } + } + + static let defaultHandler = handler(with: 300) +} + +private extension Data { + init(reading input: InputStream, size: Int = 1024) throws { + self.init() + input.open() + defer { + input.close() + } + + let bufferSize = size + let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize) + defer { + buffer.deallocate() + } + while input.hasBytesAvailable { + let read = input.read(buffer, maxLength: bufferSize) + if read < 0 { + // Stream error occured + throw input.streamError! + } else if read == 0 { + // EOF + break + } + self.append(buffer, count: read) + } + } +} diff --git a/DuckDuckGoTests/AdAttributionPixelReporterTests.swift b/DuckDuckGoTests/AdAttributionPixelReporterTests.swift new file mode 100644 index 0000000000..97eef8546e --- /dev/null +++ b/DuckDuckGoTests/AdAttributionPixelReporterTests.swift @@ -0,0 +1,206 @@ +// +// AdAttributionPixelReporterTests.swift +// DuckDuckGo +// +// 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 Core +import XCTest + +@testable import DuckDuckGo + +final class AdAttributionPixelReporterTests: XCTestCase { + + private var attributionFetcher: AdAttributionFetcherMock! + private var fetcherStorage: AdAttributionReporterStorageMock! + + override func setUpWithError() throws { + attributionFetcher = AdAttributionFetcherMock() + fetcherStorage = AdAttributionReporterStorageMock() + } + + override func tearDownWithError() throws { + attributionFetcher = nil + fetcherStorage = nil + PixelFiringMock.tearDown() + } + + func testReportsAttribution() async { + let sut = createSUT() + attributionFetcher.fetchResponse = AdServicesAttributionResponse(attribution: true) + + let result = await sut.reportAttributionIfNeeded() + + XCTAssertEqual(PixelFiringMock.lastPixel, .appleAdAttribution) + XCTAssertTrue(result) + } + + func testReportsOnce() async { + let sut = createSUT() + attributionFetcher.fetchResponse = AdServicesAttributionResponse(attribution: true) + + await fetcherStorage.markAttributionReportSuccessful() + let result = await sut.reportAttributionIfNeeded() + + XCTAssertNil(PixelFiringMock.lastPixel) + XCTAssertFalse(result) + } + + func testPixelname() async { + let sut = createSUT() + attributionFetcher.fetchResponse = AdServicesAttributionResponse(attribution: true) + + let result = await sut.reportAttributionIfNeeded() + + XCTAssertEqual(PixelFiringMock.lastPixel?.name, "m_apple-ad-attribution") + XCTAssertTrue(result) + } + + func testPixelAttributesNaming() async throws { + let sut = createSUT() + attributionFetcher.fetchResponse = AdServicesAttributionResponse(attribution: true) + + await sut.reportAttributionIfNeeded() + + let pixelAttributes = try XCTUnwrap(PixelFiringMock.lastParams) + + XCTAssertEqual(pixelAttributes["org_id"], "1") + XCTAssertEqual(pixelAttributes["campaign_id"], "2") + XCTAssertEqual(pixelAttributes["conversion_type"], "conversionType") + XCTAssertEqual(pixelAttributes["ad_group_id"], "3") + XCTAssertEqual(pixelAttributes["country_or_region"], "countryOrRegion") + XCTAssertEqual(pixelAttributes["keyword_id"], "4") + XCTAssertEqual(pixelAttributes["ad_id"], "5") + } + + func testPixelAttributes_WhenPartialAttributionData() async throws { + let sut = createSUT() + attributionFetcher.fetchResponse = AdServicesAttributionResponse( + attribution: true, + orgId: 1, + campaignId: 2, + conversionType: "conversionType", + adGroupId: nil, + countryOrRegion: nil, + keywordId: nil, + adId: nil + ) + + await sut.reportAttributionIfNeeded() + + let pixelAttributes = try XCTUnwrap(PixelFiringMock.lastParams) + + XCTAssertEqual(pixelAttributes["org_id"], "1") + XCTAssertEqual(pixelAttributes["campaign_id"], "2") + XCTAssertEqual(pixelAttributes["conversion_type"], "conversionType") + XCTAssertNil(pixelAttributes["ad_group_id"]) + XCTAssertNil(pixelAttributes["country_or_region"]) + XCTAssertNil(pixelAttributes["keyword_id"]) + XCTAssertNil(pixelAttributes["ad_id"]) + } + + func testPixelNotFiredAndMarksReport_WhenAttributionFalse() async { + let sut = createSUT() + attributionFetcher.fetchResponse = AdServicesAttributionResponse(attribution: false) + + let result = await sut.reportAttributionIfNeeded() + + XCTAssertNil(PixelFiringMock.lastPixel) + XCTAssertTrue(fetcherStorage.wasAttributionReportSuccessful) + XCTAssertTrue(result) + } + + func testPixelNotFiredAndReportNotMarked_WhenAttributionUnavailable() async { + let sut = createSUT() + attributionFetcher.fetchResponse = nil + + let result = await sut.reportAttributionIfNeeded() + + XCTAssertNil(PixelFiringMock.lastPixel) + XCTAssertFalse(fetcherStorage.wasAttributionReportSuccessful) + XCTAssertFalse(result) + } + + func testDoesNotMarkSuccessful_WhenPixelFiringFailed() async { + let sut = createSUT() + attributionFetcher.fetchResponse = AdServicesAttributionResponse(attribution: true) + PixelFiringMock.expectedFireError = NSError(domain: "PixelFailure", code: 1) + + let result = await sut.reportAttributionIfNeeded() + + XCTAssertFalse(fetcherStorage.wasAttributionReportSuccessful) + XCTAssertFalse(result) + } + + private func createSUT() -> AdAttributionPixelReporter { + AdAttributionPixelReporter(fetcherStorage: fetcherStorage, + attributionFetcher: attributionFetcher, + pixelFiring: PixelFiringMock.self) + } +} + +class AdAttributionReporterStorageMock: AdAttributionReporterStorage { + func markAttributionReportSuccessful() async { + wasAttributionReportSuccessful = true + } + + private(set) var wasAttributionReportSuccessful: Bool = false +} + +class AdAttributionFetcherMock: AdAttributionFetcher { + var fetchResponse: AdServicesAttributionResponse? + func fetch() async -> AdServicesAttributionResponse? { + fetchResponse + } +} + +final actor PixelFiringMock: PixelFiring { + static var expectedFireError: Error? + + static var lastParams: [String: String]? + static var lastPixel: Pixel.Event? + static func fire(pixel: Pixel.Event, withAdditionalParameters params: [String: String]) async throws { + lastParams = params + lastPixel = pixel + + if let expectedFireError { + throw expectedFireError + } + } + + static func tearDown() { + lastParams = nil + lastPixel = nil + expectedFireError = nil + } + + private init() {} +} + +extension AdServicesAttributionResponse { + init(attribution: Bool) { + self.init( + attribution: attribution, + orgId: 1, + campaignId: 2, + conversionType: "conversionType", + adGroupId: 3, + countryOrRegion: "countryOrRegion", + keywordId: 4, + adId: 5 + ) + } +}