Skip to content

Commit

Permalink
Merge remote-tracking branch 'origin/main' into alex/malware-protecti…
Browse files Browse the repository at this point in the history
…on-pixels
  • Loading branch information
mallexxx committed Dec 18, 2024
2 parents 723dbb9 + b71ed70 commit 53e3c28
Show file tree
Hide file tree
Showing 14 changed files with 1,063 additions and 100 deletions.
96 changes: 65 additions & 31 deletions Sources/PixelExperimentKit/PixelExperimentKit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import BrowserServicesKit
import Foundation

public typealias ConversionWindow = ClosedRange<Int>
public typealias NumberOfCalls = Int

struct ExperimentEvent: PixelKitEvent {
var name: String
Expand Down Expand Up @@ -72,7 +73,7 @@ extension PixelKit {
ExperimentConfig.fireFunction(event, .uniqueByNameAndParameters, false)
}

/// Fires a pixel for a specific action in an experiment, based on conversion window and value thresholds (if value is a number).
/// Fires a pixel for a specific action in an experiment, based on conversion window and value.
/// - Parameters:
/// - subfeatureID: Identifier for the subfeature associated with the experiment.
/// - metric: The name of the metric being tracked (e.g., "searches").
Expand All @@ -82,7 +83,7 @@ extension PixelKit {
/// This function:
/// 1. Validates if the experiment is active.
/// 2. Ensures the user is within the specified conversion window.
/// 3. Tracks actions performed and sends the pixel once the target value is reached (if applicable).
/// 3. Sends the pixel if not sent before (unique by name and parameter)
public static func fireExperimentPixel(for subfeatureID: SubfeatureID,
metric: String,
conversionWindowDays: ConversionWindow,
Expand All @@ -94,11 +95,41 @@ extension PixelKit {
}
guard let experimentData = featureFlagger.getAllActiveExperiments()[subfeatureID] else { return }

// Check if within conversion window
guard isUserInConversionWindow(conversionWindowDays, enrollmentDate: experimentData.enrollmentDate) else { return }

// Define event
let event = event(for: subfeatureID, experimentData: experimentData, conversionWindowDays: conversionWindowDays, metric: metric, value: value)
ExperimentConfig.fireFunction(event, .uniqueByNameAndParameters, false)
}

/// Fires a pixel for a specific action in an experiment, based on conversion window and value thresholds.
/// - Parameters:
/// - subfeatureID: Identifier for the subfeature associated with the experiment.
/// - metric: The name of the metric being tracked (e.g., "searches").
/// - conversionWindowDays: The range of days after enrollment during which the action is valid.
/// - numberOfCalls: target number of actions required to fire the pixel.
///
/// This function:
/// 1. Validates if the experiment is active.
/// 2. Ensures the user is within the specified conversion window.
/// 3. Tracks actions performed and sends the pixel once the target value is reached (if applicable).
public static func fireExperimentPixelIfThresholdReached(for subfeatureID: SubfeatureID,
metric: String,
conversionWindowDays: ConversionWindow,
threshold: NumberOfCalls) {
// Check is active experiment for user
guard let featureFlagger = ExperimentConfig.featureFlagger else {
assertionFailure("PixelKit is not configured for experiments")
return
}
guard let experimentData = featureFlagger.getAllActiveExperiments()[subfeatureID] else { return }

fireExperimentPixelForActiveExperiment(subfeatureID,
experimentData: experimentData,
metric: metric,
conversionWindowDays: conversionWindowDays,
value: value)
numberOfCalls: threshold)
}

/// Fires search-related experiment pixels for all active experiments.
Expand Down Expand Up @@ -172,7 +203,7 @@ extension PixelKit {
experimentData: experimentData,
metric: metric,
conversionWindowDays: range,
value: "\(value)"
numberOfCalls: value
)
}
}
Expand All @@ -182,39 +213,24 @@ extension PixelKit {
experimentData: ExperimentData,
metric: String,
conversionWindowDays: ConversionWindow,
value: String) {
numberOfCalls: Int) {
// Set parameters, event name, store key
let eventName = "\(Constants.metricsEventPrefix)_\(subfeatureID)_\(experimentData.cohortID)"
let conversionWindowValue = (conversionWindowDays.lowerBound != conversionWindowDays.upperBound) ?
"\(conversionWindowDays.lowerBound)-\(conversionWindowDays.upperBound)" :
"\(conversionWindowDays.lowerBound)"
let parameters: [String: String] = [
Constants.metricKey: metric,
Constants.conversionWindowDaysKey: conversionWindowValue,
Constants.valueKey: value,
Constants.enrollmentDateKey: experimentData.enrollmentDate.toYYYYMMDDInET()
]
let event = ExperimentEvent(name: eventName, parameters: parameters)
let eventStoreKey = "\(eventName)_\(parameters.toString())"
let event = event(for: subfeatureID, experimentData: experimentData, conversionWindowDays: conversionWindowDays, metric: metric, value: String(numberOfCalls))
let parameters = parameters(metric: metric, conversionWindowDays: conversionWindowDays, value: String(numberOfCalls), experimentData: experimentData)
let eventStoreKey = "\(event.name)_\(parameters.toString())"

// Determine if the user is within the conversion window
let isInWindow = isUserInConversionWindow(conversionWindowDays, enrollmentDate: experimentData.enrollmentDate)

// Check if value is a number
if let numberOfAction = NumberOfActions(value), numberOfAction > 1 {
// Increment or remove based on conversion window status
let shouldSendPixel = ExperimentConfig.eventTracker.incrementAndCheckThreshold(
forKey: eventStoreKey,
threshold: numberOfAction,
isInWindow: isInWindow
)
// Increment or remove based on conversion window status
let shouldSendPixel = ExperimentConfig.eventTracker.incrementAndCheckThreshold(
forKey: eventStoreKey,
threshold: numberOfCalls,
isInWindow: isInWindow
)

// Send the pixel only if conditions are met
if shouldSendPixel {
ExperimentConfig.fireFunction(event, .uniqueByNameAndParameters, false)
}
} else if isInWindow {
// If value is not a number, send the pixel only if within the window
// Send the pixel only if conditions are met
if shouldSendPixel {
ExperimentConfig.fireFunction(event, .uniqueByNameAndParameters, false)
}
}
Expand All @@ -233,6 +249,24 @@ extension PixelKit {
return currentDate >= calendar.startOfDay(for: startOfWindow) &&
currentDate <= calendar.startOfDay(for: endOfWindow)
}

private static func event(for subfeatureID: SubfeatureID, experimentData: ExperimentData, conversionWindowDays: ConversionWindow, metric: String, value: String) -> ExperimentEvent{
let eventName = "\(Constants.metricsEventPrefix)_\(subfeatureID)_\(experimentData.cohortID)"
let parameters = parameters(metric: metric, conversionWindowDays: conversionWindowDays, value: value, experimentData: experimentData)
return ExperimentEvent(name: eventName, parameters: parameters)
}

private static func parameters(metric: String, conversionWindowDays: ConversionWindow, value: String, experimentData: ExperimentData) -> [String: String] {
let conversionWindowValue = (conversionWindowDays.lowerBound != conversionWindowDays.upperBound) ?
"\(conversionWindowDays.lowerBound)-\(conversionWindowDays.upperBound)" :
"\(conversionWindowDays.lowerBound)"
return [
Constants.metricKey: metric,
Constants.conversionWindowDaysKey: conversionWindowValue,
Constants.valueKey: value,
Constants.enrollmentDateKey: experimentData.enrollmentDate.toYYYYMMDDInET()
]
}
}

extension Date {
Expand Down
1 change: 1 addition & 0 deletions Sources/PrivacyDashboard/PrivacyDashboardUserScript.swift
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,7 @@ final class PrivacyDashboardUserScript: NSObject, StaticUserScript {
{"id": "jsPerformance"},
{"id": "openerContext"},
{"id": "userRefreshCount"},
{"id": "locale"},
]
}
window.onGetToggleReportOptionsResponse(json);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import os.log
public enum AppStoreAccountManagementFlowError: Swift.Error {
case noPastTransaction
case authenticatingWithTransactionFailed
case missingAuthTokenOnRefresh
}

@available(macOS 12.0, iOS 15.0, *)
Expand All @@ -46,7 +47,8 @@ public final class DefaultAppStoreAccountManagementFlow: AppStoreAccountManageme
@discardableResult
public func refreshAuthTokenIfNeeded() async -> Result<String, AppStoreAccountManagementFlowError> {
Logger.subscription.info("[AppStoreAccountManagementFlow] refreshAuthTokenIfNeeded")
var authToken = accountManager.authToken ?? ""

guard let authToken = accountManager.authToken else { return .failure(.missingAuthTokenOnRefresh) }

// Check if auth token if still valid
if case let .failure(validateTokenError) = await authEndpointService.validateToken(accessToken: authToken) {
Expand All @@ -58,8 +60,9 @@ public final class DefaultAppStoreAccountManagementFlow: AppStoreAccountManageme
switch await authEndpointService.storeLogin(signature: lastTransactionJWSRepresentation) {
case .success(let response):
if response.externalID == accountManager.externalID {
authToken = response.authToken
accountManager.storeAuthToken(token: authToken)
let refreshedAuthToken = response.authToken
accountManager.storeAuthToken(token: refreshedAuthToken)
return .success(refreshedAuthToken)
}
case .failure(let storeLoginError):
Logger.subscription.error("[AppStoreAccountManagementFlow] storeLogin error: \(String(reflecting: storeLoginError), privacy: .public)")
Expand Down
21 changes: 21 additions & 0 deletions Sources/Subscription/Flows/Models/SubscriptionOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ public enum SubscriptionPlatformName: String, Encodable {
public struct SubscriptionOption: Encodable, Equatable {
let id: String
let cost: SubscriptionOptionCost
let offer: SubscriptionOptionOffer?

init(id: String, cost: SubscriptionOptionCost, offer: SubscriptionOptionOffer? = nil) {
self.id = id
self.cost = cost
self.offer = offer
}
}

struct SubscriptionOptionCost: Encodable, Equatable {
Expand All @@ -60,3 +67,17 @@ struct SubscriptionOptionCost: Encodable, Equatable {
public struct SubscriptionFeature: Encodable, Equatable {
let name: Entitlement.ProductName
}

/// A `SubscriptionOptionOffer` represents an offer (e.g Free Trials) associated with a Subscription
public struct SubscriptionOptionOffer: Encodable, Equatable {

public enum OfferType: String, Codable, CaseIterable {
case freeTrial
}

let type: OfferType
let id: String
let displayPrice: String
let durationInDays: Int
let isUserEligible: Bool
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
//
// ProductFetching.swift
//
// 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 StoreKit

/// A protocol for types that can fetch subscription products.
@available(macOS 12.0, iOS 15.0, *)
public protocol ProductFetching {
/// Fetches products for the specified identifiers.
/// - Parameter identifiers: An array of product identifiers to fetch.
/// - Returns: An array of subscription products.
/// - Throws: An error if the fetch operation fails.
func products(for identifiers: [String]) async throws -> [any SubscriptionProduct]
}

/// A default implementation of ProductFetching that uses StoreKit's standard product fetching.
@available(macOS 12.0, iOS 15.0, *)
public final class DefaultProductFetcher: ProductFetching {
/// Initializes a new DefaultProductFetcher instance.
public init() {}

/// Fetches products using StoreKit's Product.products API.
/// - Parameter identifiers: An array of product identifiers to fetch.
/// - Returns: An array of subscription products.
/// - Throws: An error if the fetch operation fails.
public func products(for identifiers: [String]) async throws -> [any SubscriptionProduct] {
return try await Product.products(for: identifiers)
}
}
Loading

0 comments on commit 53e3c28

Please sign in to comment.