Skip to content

Commit

Permalink
Merge branch 'main' into sam/improve-vpn-metadata
Browse files Browse the repository at this point in the history
  • Loading branch information
samsymons authored Mar 1, 2024
2 parents e04bd62 + fa3ca3f commit 23a679a
Show file tree
Hide file tree
Showing 47 changed files with 1,831 additions and 322 deletions.
2 changes: 1 addition & 1 deletion .xcode-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
15.1
15.2
1 change: 1 addition & 0 deletions Core/NetworkProtectionNotificationIdentifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,5 @@ public enum NetworkProtectionNotificationIdentifier: String {
case connection = "network-protection.notification.connection"
case superseded = "network-protection.notification.superseded"
case test = "network-protection.notification.test"
case entitlement = "network-protection.notification.entitlement"
}
9 changes: 9 additions & 0 deletions Core/Pixel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
24 changes: 23 additions & 1 deletion Core/PixelEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,16 @@ extension Pixel {
case compilationFailed

case appRatingPromptFetchError

case appleAdAttribution

case userBehaviorReloadTwice
case userBehaviorReloadAndRestart
case userBehaviorReloadAndFireButton
case userBehaviorReloadAndOpenSettings
case userBehaviorReloadAndTogglePrivacyControls
case userBehaviorFireButtonAndRestart
case userBehaviorFireButtonAndTogglePrivacyControls
}

}
Expand Down Expand Up @@ -899,7 +909,7 @@ extension Pixel.Event {

case .blankOverlayNotDismissed: return "m_d_ovs"

case .cookieDeletionTimedOut: return "m_d_csto"
case .cookieDeletionTimedOut: return "m_debug_cookie-clearing-timeout"
case .cookieDeletionLeftovers: return "m_cookie_deletion_leftovers"

case .cachedTabPreviewsExceedsTabCount: return "m_d_tpetc"
Expand Down Expand Up @@ -1024,6 +1034,18 @@ 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"
case .userBehaviorReloadAndRestart: return "m_reload-and-restart"
case .userBehaviorReloadAndFireButton: return "m_reload-and-fire-button"
case .userBehaviorReloadAndOpenSettings: return "m_reload-and-open-settings"
case .userBehaviorReloadAndTogglePrivacyControls: return "m_reload-and-toggle-privacy-controls"
case .userBehaviorFireButtonAndRestart: return "m_fire-button-and-restart"
case .userBehaviorFireButtonAndTogglePrivacyControls: return "m_fire-button-and-toggle-privacy-controls"
}

}
Expand Down
5 changes: 5 additions & 0 deletions Core/UserDefaultsPropertyWrapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,11 @@ public struct UserDefaultsWrapper<T> {
case privacyConfigCustomURL = "com.duckduckgo.ios.privacyConfigCustomURL"

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
Expand Down
1 change: 1 addition & 0 deletions Core/WebCacheManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ extension WebCacheManager {

private func legacyDataClearing() async -> [HTTPCookie]? {
let timeoutTask = Task.detached {
try? await Task.sleep(interval: 5.0)
if !Task.isCancelled {
Pixel.fire(pixel: .cookieDeletionTimedOut, withAdditionalParameters: [
PixelParameters.clearWebDataTimedOut: "1"
Expand Down
269 changes: 161 additions & 108 deletions DuckDuckGo.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
{
"pins" : [
{
"identity" : "apple-toolbox",
"kind" : "remoteSourceControl",
"location" : "https://github.com/duckduckgo/apple-toolbox.git",
"state" : {
"revision" : "e3dc4faf70ca09718a2d20d5c47b449389e8c153",
"version" : "1.0.0"
}
},
{
"identity" : "bloom_cpp",
"kind" : "remoteSourceControl",
Expand All @@ -14,8 +23,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/DuckDuckGo/BrowserServicesKit",
"state" : {
"revision" : "04c35220aa94bd005171086acccadd677400e7d5",
"version" : "111.0.2"
"revision" : "045a8782c3dbbf79fc088b38120dea1efadc13e1",
"version" : "114.1.0"
}
},
{
Expand All @@ -32,8 +41,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/duckduckgo/content-scope-scripts",
"state" : {
"revision" : "36ddba2cbac52a41b9a9275af06d32fa8a56d2d7",
"version" : "4.64.0"
"revision" : "a3690b7666a3617693383d948cb492513f6aa569",
"version" : "5.0.0"
}
},
{
Expand Down
159 changes: 159 additions & 0 deletions DuckDuckGo/AdAttribution/AdAttributionFetcher.swift
Original file line number Diff line number Diff line change
@@ -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..<Constant.maxRetries {
do {
try Task.checkCancellation()

let token = try (lastToken ?? tokenGetter())
lastToken = token
return try await fetchAttributionData(using: token)
} catch let error as AdAttributionFetcherError {
os_log("AdAttributionFetcher failed to fetch attribution data: %@. Retrying.", log: .adAttributionLog, error.localizedDescription)

if error == .invalidToken {
lastToken = nil
}

if error.allowsRetry {
try? await Task.sleep(interval: retryInterval)
continue
} else {
break
}
} catch {
os_log("AdAttributionFetcher failed to fetch attribution data: %@", log: .adAttributionLog, error.localizedDescription)

// Do not retry
break
}
}

return nil
}

private func fetchAttributionData(using token: String) async throws -> 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
}
}
}
96 changes: 96 additions & 0 deletions DuckDuckGo/AdAttribution/AdAttributionPixelReporter.swift
Original file line number Diff line number Diff line change
@@ -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<Void, Error>) in
Pixel.fire(pixel: pixel, withAdditionalParameters: params) { error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume()
}
}
}
}
}
Loading

0 comments on commit 23a679a

Please sign in to comment.