Skip to content

Commit

Permalink
Cherry pick Subscription Attribution changes (#2790)
Browse files Browse the repository at this point in the history
Task/Issue URL: https://app.asana.com/0/1199230911884351/1205406454505225/f

**Description**:
Cherry pick the changes from this [PR](#2761)
  • Loading branch information
alessandroboron authored May 17, 2024
1 parent 5864a4a commit 0a060e9
Show file tree
Hide file tree
Showing 20 changed files with 551 additions and 109 deletions.
62 changes: 61 additions & 1 deletion DuckDuckGo.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

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" : "ada5f68970f098b3230dbd80a25cd048a606ac12",
"version" : "144.0.7-3"
"revision" : "43db1d59455246547fc4ea3998f07751dfa77166",
"version" : "144.0.7-4"
}
},
{
Expand Down
97 changes: 97 additions & 0 deletions DuckDuckGo/Common/Attribution/AttributionPixelHandler.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
//
// AttributionPixelHandler.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 PixelKit

// A type that send pixels that needs attributions parameters.
protocol AttributionPixelHandler {
func fireAttributionPixel(
event: PixelKit.Event,
frequency: PixelKit.Frequency,
origin: String?,
additionalParameters: [String: String]?
)
}

final class GenericAttributionPixelHandler: AttributionPixelHandler {
enum Parameters {
static let origin = "origin"
static let locale = "locale"
}

private let fireRequest: FireRequest
private let locale: Locale

/// Creates an instance with the specified fire request, origin provider and locale.
/// - Parameters:
/// - fireRequest: A function for sending the Pixel request.
/// - locale: The locale of the device.
init(
fireRequest: @escaping FireRequest = PixelKit.fire,
locale: Locale = .current
) {
self.fireRequest = fireRequest
self.locale = locale
}

func fireAttributionPixel(
event: PixelKit.Event,
frequency: PixelKit.Frequency,
origin: String?,
additionalParameters: [String: String]?
) {
fireRequest(
event,
frequency,
[:],
self.parameters(additionalParameters, withOrigin: origin, locale: locale.identifier),
nil,
nil,
true, { _, _ in }
)
}
}

// MARK: - Parameter

private extension GenericAttributionPixelHandler {
func parameters(_ parameters: [String: String]?, withOrigin origin: String?, locale: String) -> [String: String] {
var parameters = parameters ?? [:]
parameters[Self.Parameters.locale] = locale
if let origin {
parameters[Self.Parameters.origin] = origin
}
return parameters
}
}

// MARK: - FireRequest

extension GenericAttributionPixelHandler {
typealias FireRequest = (
_ event: PixelKit.Event,
_ frequency: PixelKit.Frequency,
_ headers: [String: String],
_ parameters: [String: String]?,
_ error: Error?,
_ allowedQueryReservedCharacters: CharacterSet?,
_ includeAppVersionParameter: Bool,
_ onComplete: @escaping (Bool, Error?) -> Void
) -> Void
}
1 change: 1 addition & 0 deletions DuckDuckGo/Common/Extensions/URLExtension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -581,4 +581,5 @@ extension URL {
return false
}
}

}
63 changes: 10 additions & 53 deletions DuckDuckGo/Statistics/ATB/InstallationAttributionPixelHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,72 +20,29 @@ import Foundation
import PixelKit

/// A type that handles Pixels for acquisition attributions.
protocol AttributionsPixelHandler: AnyObject {
protocol InstallationAttributionsPixelHandler: AnyObject {
/// Fire the Pixel to track the App install.
func fireInstallationAttributionPixel()
}

final class InstallationAttributionPixelHandler: AttributionsPixelHandler {
enum Parameters {
static let origin = "origin"
static let locale = "locale"
}

private let fireRequest: FireRequest
final class AppInstallationAttributionPixelHandler: InstallationAttributionsPixelHandler {
private let originProvider: AttributionOriginProvider
private let locale: Locale
private let decoratedAttributionPixelHandler: AttributionPixelHandler

/// Creates an instance with the specified fire request, origin provider and locale.
/// - Parameters:
/// - fireRequest: A function for sending the Pixel request.
/// - originProvider: A provider for the origin used to track the acquisition funnel.
/// - locale: The locale of the device.
init(
fireRequest: @escaping FireRequest = PixelKit.fire,
originProvider: AttributionOriginProvider = AttributionOriginFileProvider(),
locale: Locale = .current
attributionPixelHandler: AttributionPixelHandler = GenericAttributionPixelHandler()
) {
self.fireRequest = fireRequest
self.originProvider = originProvider
self.locale = locale
decoratedAttributionPixelHandler = attributionPixelHandler
}

func fireInstallationAttributionPixel() {
fireRequest(
GeneralPixel.installationAttribution,
.legacyInitial,
[:],
additionalParameters(origin: originProvider.origin, locale: locale.identifier),
nil,
nil,
true, { _, _ in }
decoratedAttributionPixelHandler.fireAttributionPixel(
event: GeneralPixel.installationAttribution,
frequency: .legacyInitial,
origin: originProvider.origin,
additionalParameters: nil
)
}
}

// MARK: - Parameter

private extension InstallationAttributionPixelHandler {
func additionalParameters(origin: String?, locale: String) -> [String: String] {
var dictionary = [Self.Parameters.locale: locale]
if let origin {
dictionary[Self.Parameters.origin] = origin
}
return dictionary
}
}

// MARK: - FireRequest

extension InstallationAttributionPixelHandler {
typealias FireRequest = (
_ event: PixelKit.Event,
_ frequency: PixelKit.Frequency,
_ headers: [String: String],
_ parameters: [String: String]?,
_ error: Error?,
_ allowedQueryReservedCharacters: CharacterSet?,
_ includeAppVersionParameter: Bool,
_ onComplete: @escaping (Bool, Error?) -> Void
) -> Void
}
4 changes: 2 additions & 2 deletions DuckDuckGo/Statistics/ATB/StatisticsLoader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,14 @@ final class StatisticsLoader {

private let statisticsStore: StatisticsStore
private let emailManager: EmailManager
private let attributionPixelHandler: AttributionsPixelHandler
private let attributionPixelHandler: InstallationAttributionsPixelHandler
private let parser = AtbParser()
private var isAppRetentionRequestInProgress = false

init(
statisticsStore: StatisticsStore = LocalStatisticsStore(),
emailManager: EmailManager = EmailManager(),
attributionPixelHandler: AttributionsPixelHandler = InstallationAttributionPixelHandler()
attributionPixelHandler: InstallationAttributionsPixelHandler = AppInstallationAttributionPixelHandler()
) {
self.statisticsStore = statisticsStore
self.emailManager = emailManager
Expand Down
2 changes: 2 additions & 0 deletions DuckDuckGo/Statistics/PrivacyProPixel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ enum PrivacyProPixel: PixelKitEventV2 {
case privacyProSubscriptionManagementPlanBilling
case privacyProSubscriptionManagementRemoval
case privacyProPurchaseStripeSuccess
case privacyProSuccessfulSubscriptionAttribution
// Web pixels
case privacyProOfferMonthlyPriceClick
case privacyProOfferYearlyPriceClick
Expand Down Expand Up @@ -110,6 +111,7 @@ enum PrivacyProPixel: PixelKitEventV2 {
case .privacyProSubscriptionManagementPlanBilling: return "m_mac_\(appDistribution)_privacy-pro_settings_change-plan-or-billing_click"
case .privacyProSubscriptionManagementRemoval: return "m_mac_\(appDistribution)_privacy-pro_settings_remove-from-device_click"
case .privacyProPurchaseStripeSuccess: return "m_mac_\(appDistribution)_privacy-pro_app_subscription-purchase_stripe_success"
case .privacyProSuccessfulSubscriptionAttribution: return "m_mac_\(appDistribution)_subscribe"
// Web
case .privacyProOfferMonthlyPriceClick: return "m_mac_\(appDistribution)_privacy-pro_offer_monthly-price_click"
case .privacyProOfferYearlyPriceClick: return "m_mac_\(appDistribution)_privacy-pro_offer_yearly-price_click"
Expand Down
46 changes: 46 additions & 0 deletions DuckDuckGo/Subscription/SubscriptionAttributionPixelHandler.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
//
// SubscriptionAttributionPixelHandler.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 Subscription

protocol SubscriptionAttributionPixelHandler: AnyObject {
var origin: String? { get set }
func fireSuccessfulSubscriptionAttributionPixel()
}

// MARK: - SubscriptionAttributionPixelHandler

final class PrivacyProSubscriptionAttributionPixelHandler: SubscriptionAttributionPixelHandler {
var origin: String?
private let decoratedAttributionPixelHandler: AttributionPixelHandler

init(attributionPixelHandler: AttributionPixelHandler = GenericAttributionPixelHandler()) {
decoratedAttributionPixelHandler = attributionPixelHandler
}

func fireSuccessfulSubscriptionAttributionPixel() {
decoratedAttributionPixelHandler.fireAttributionPixel(
event: PrivacyProPixel.privacyProSuccessfulSubscriptionAttribution,
frequency: .standard,
origin: origin,
additionalParameters: nil
)
}

}
66 changes: 66 additions & 0 deletions DuckDuckGo/Subscription/SubscriptionRedirectManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
//
// SubscriptionRedirectManager.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 Subscription
import BrowserServicesKit

protocol SubscriptionRedirectManager: AnyObject {
func redirectURL(for url: URL) -> URL?
}

final class PrivacyProSubscriptionRedirectManager: SubscriptionRedirectManager {
private let featureAvailabiltyProvider: () -> Bool

init(featureAvailabiltyProvider: @escaping @autoclosure () -> Bool = DefaultSubscriptionFeatureAvailability().isFeatureAvailable) {
self.featureAvailabiltyProvider = featureAvailabiltyProvider
}

func redirectURL(for url: URL) -> URL? {
guard url.isPart(ofDomain: "duckduckgo.com") else { return nil }

if url.pathComponents == URL.privacyPro.pathComponents {
let isFeatureAvailable = featureAvailabiltyProvider()
let shouldHidePrivacyProDueToNoProducts = SubscriptionPurchaseEnvironment.current == .appStore && SubscriptionPurchaseEnvironment.canPurchase == false
let isPurchasePageRedirectActive = isFeatureAvailable && !shouldHidePrivacyProDueToNoProducts
// Redirect the `/pro` URL to `/subscriptions` URL. If there are any query items in the original URL it appends to the `/subscriptions` URL.
return isPurchasePageRedirectActive ? URL.subscriptionBaseURL.addingQueryItems(from: url) : nil
}

return nil
}

}

private extension URL {

func addingQueryItems(from url: URL) -> URL {
// If the origin value is of type "do+something" appending the percentEncodedQueryItem crashes the browser as + is replaced by a space.
// Perform encoding on the value to avoid the crash.
guard let queryItems = url.getQueryItems()?
.compactMap({ queryItem -> URLQueryItem? in
guard let value = queryItem.value else { return nil }
let encodedValue = value.percentEncoded(withAllowedCharacters: .urlQueryParameterAllowed)
return URLQueryItem(name: queryItem.name, value: encodedValue)
})
else { return self }

return self.appending(percentEncodedQueryItems: queryItems)
}

}
23 changes: 7 additions & 16 deletions DuckDuckGo/Tab/Navigation/RedirectNavigationResponder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,17 @@

import Navigation
import Foundation
import Subscription
import BrowserServicesKit

struct RedirectNavigationResponder: NavigationResponder {

private let redirectManager: SubscriptionRedirectManager

init(redirectManager: SubscriptionRedirectManager = PrivacyProSubscriptionRedirectManager()) {
self.redirectManager = redirectManager
}

func decidePolicy(for navigationAction: NavigationAction, preferences: inout NavigationPreferences) async -> NavigationActionPolicy? {
guard let mainFrame = navigationAction.mainFrameTarget, let redirectURL = redirectURL(for: navigationAction.url) else { return .next }
guard let mainFrame = navigationAction.mainFrameTarget, let redirectURL = redirectManager.redirectURL(for: navigationAction.url) else { return .next }

return .redirect(mainFrame) { navigator in
var request = navigationAction.request
Expand All @@ -33,17 +37,4 @@ struct RedirectNavigationResponder: NavigationResponder {
}
}

private func redirectURL(for url: URL) -> URL? {
guard url.isPart(ofDomain: "duckduckgo.com") else { return nil }

if url.pathComponents == URL.privacyPro.pathComponents {
let isFeatureAvailable = DefaultSubscriptionFeatureAvailability().isFeatureAvailable
let shouldHidePrivacyProDueToNoProducts = SubscriptionPurchaseEnvironment.current == .appStore && SubscriptionPurchaseEnvironment.canPurchase == false
let isPurchasePageRedirectActive = isFeatureAvailable && !shouldHidePrivacyProDueToNoProducts

return isPurchasePageRedirectActive ? URL.subscriptionBaseURL : nil
}

return nil
}
}
Loading

0 comments on commit 0a060e9

Please sign in to comment.