Skip to content

Commit

Permalink
Merge tag '211.1.3-1'
Browse files Browse the repository at this point in the history
  • Loading branch information
miasma13 committed Nov 29, 2024
2 parents 9563e1e + 114cdbf commit 09fd124
Show file tree
Hide file tree
Showing 21 changed files with 574 additions and 63 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,8 @@ public enum PrivacyProSubfeature: String, Equatable, PrivacySubfeature {
case isLaunchedOverrideStripe
case useUnifiedFeedback
case setAccessTokenCookieForSubscriptionDomains
case isLaunchedROW
case isLaunchedROWOverride
}

public enum SslCertificatesSubfeature: String, PrivacySubfeature {
Expand Down
1 change: 1 addition & 0 deletions Sources/Subscription/API/Model/Entitlement.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ public struct Entitlement: Codable, Equatable {
case networkProtection = "Network Protection"
case dataBrokerProtection = "Data Broker Protection"
case identityTheftRestoration = "Identity Theft Restoration"
case identityTheftRestorationGlobal = "Global Identity Theft Restoration"
case unknown

public init(from decoder: Decoder) throws {
Expand Down
11 changes: 11 additions & 0 deletions Sources/Subscription/API/SubscriptionEndpointService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ public struct GetProductsItem: Decodable {
public let currency: String
}

public struct GetSubscriptionFeaturesResponse: Decodable {
public let features: [Entitlement.ProductName]
}

public struct GetCustomerPortalURLResponse: Decodable {
public let customerPortalUrl: String
}
Expand All @@ -47,6 +51,7 @@ public protocol SubscriptionEndpointService {
func getSubscription(accessToken: String, cachePolicy: APICachePolicy) async -> Result<Subscription, SubscriptionServiceError>
func signOut()
func getProducts() async -> Result<[GetProductsItem], APIServiceError>
func getSubscriptionFeatures(for subscriptionID: String) async -> Result<GetSubscriptionFeaturesResponse, APIServiceError>
func getCustomerPortalURL(accessToken: String, externalID: String) async -> Result<GetCustomerPortalURLResponse, APIServiceError>
func confirmPurchase(accessToken: String, signature: String) async -> Result<ConfirmPurchaseResponse, APIServiceError>
}
Expand Down Expand Up @@ -137,6 +142,12 @@ public struct DefaultSubscriptionEndpointService: SubscriptionEndpointService {

// MARK: -

public func getSubscriptionFeatures(for subscriptionID: String) async -> Result<GetSubscriptionFeaturesResponse, APIServiceError> {
await apiService.executeAPICall(method: "GET", endpoint: "products/\(subscriptionID)/features", headers: nil, body: nil)
}

// MARK: -

public func getCustomerPortalURL(accessToken: String, externalID: String) async -> Result<GetCustomerPortalURLResponse, APIServiceError> {
var headers = apiService.makeAuthorizationHeader(for: accessToken)
headers["externalAccountId"] = externalID
Expand Down
33 changes: 33 additions & 0 deletions Sources/Subscription/FeatureFlags/FeatureFlaggerMapping.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
//
// FeatureFlaggerMapping.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

open class FeatureFlaggerMapping<Feature> {
public typealias Mapping = (_ feature: Feature) -> Bool

private let isFeatureEnabledMapping: Mapping

public init(mapping: @escaping Mapping) {
isFeatureEnabledMapping = mapping
}

public func isFeatureOn(_ feature: Feature) -> Bool {
return isFeatureEnabledMapping(feature)
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// SubscriptionEnvironmentNames.swift
// SubscriptionFeatureFlags.swift
//
// Copyright © 2024 DuckDuckGo. All rights reserved.
//
Expand All @@ -18,18 +18,21 @@

import Foundation

public enum SubscriptionFeatureName: String, CaseIterable {
case privateBrowsing = "private-browsing"
case privateSearch = "private-search"
case emailProtection = "email-protection"
case appTrackingProtection = "app-tracking-protection"
case vpn = "vpn"
case personalInformationRemoval = "personal-information-removal"
case identityTheftRestoration = "identity-theft-restoration"
public enum SubscriptionFeatureFlags {
case isLaunchedROW
case isLaunchedROWOverride
case usePrivacyProUSARegionOverride
case usePrivacyProROWRegionOverride
}

public enum SubscriptionPlatformName: String {
case ios
case macos
case stripe
public extension SubscriptionFeatureFlags {

var defaultState: Bool {
switch self {
case .isLaunchedROW, .isLaunchedROWOverride:
return true
case .usePrivacyProUSARegionOverride, .usePrivacyProROWRegionOverride:
return false
}
}
}
21 changes: 17 additions & 4 deletions Sources/Subscription/Flows/Models/SubscriptionOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,21 +19,34 @@
import Foundation

public struct SubscriptionOptions: Encodable, Equatable {
let platform: String
let platform: SubscriptionPlatformName
let options: [SubscriptionOption]
let features: [SubscriptionFeature]

public static var empty: SubscriptionOptions {
let features = SubscriptionFeatureName.allCases.map { SubscriptionFeature(name: $0.rawValue) }
let features = [SubscriptionFeature(name: .networkProtection),
SubscriptionFeature(name: .dataBrokerProtection),
SubscriptionFeature(name: .identityTheftRestoration)]
let platform: SubscriptionPlatformName
#if os(iOS)
platform = .ios
#else
platform = .macos
#endif
return SubscriptionOptions(platform: platform.rawValue, options: [], features: features)
return SubscriptionOptions(platform: platform, options: [], features: features)
}

public func withoutPurchaseOptions() -> Self {
SubscriptionOptions(platform: platform, options: [], features: features)
}
}

public enum SubscriptionPlatformName: String, Encodable {
case ios
case macos
case stripe
}

public struct SubscriptionOption: Encodable, Equatable {
let id: String
let cost: SubscriptionOptionCost
Expand All @@ -45,5 +58,5 @@ struct SubscriptionOptionCost: Encodable, Equatable {
}

public struct SubscriptionFeature: Encodable, Equatable {
let name: String
let name: Entitlement.ProductName
}
6 changes: 4 additions & 2 deletions Sources/Subscription/Flows/Stripe/StripePurchaseFlow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,11 @@ public final class DefaultStripePurchaseFlow: StripePurchaseFlow {
cost: cost)
}

let features = SubscriptionFeatureName.allCases.map { SubscriptionFeature(name: $0.rawValue) }
let features = [SubscriptionFeature(name: .networkProtection),
SubscriptionFeature(name: .dataBrokerProtection),
SubscriptionFeature(name: .identityTheftRestoration)]

return .success(SubscriptionOptions(platform: SubscriptionPlatformName.stripe.rawValue,
return .success(SubscriptionOptions(platform: SubscriptionPlatformName.stripe,
options: options,
features: features))
}
Expand Down
106 changes: 90 additions & 16 deletions Sources/Subscription/Managers/StorePurchaseManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ public protocol StorePurchaseManager {
var purchasedProductIDs: [String] { get }
var purchaseQueue: [String] { get }
var areProductsAvailable: Bool { get }
var currentStorefrontRegion: SubscriptionRegion { get }

@MainActor func syncAppleIDAccount() async throws
@MainActor func updateAvailableProducts() async
Expand All @@ -56,21 +57,24 @@ public protocol StorePurchaseManager {
@available(macOS 12.0, iOS 15.0, *)
public final class DefaultStorePurchaseManager: ObservableObject, StorePurchaseManager {

let productIdentifiers = ["ios.subscription.1month", "ios.subscription.1year",
"subscription.1month", "subscription.1year",
"review.subscription.1month", "review.subscription.1year",
"tf.sandbox.subscription.1month", "tf.sandbox.subscription.1year",
"ddg.privacy.pro.monthly.renews.us", "ddg.privacy.pro.yearly.renews.us"]
private let storeSubscriptionConfiguration: StoreSubscriptionConfiguration
private let subscriptionFeatureMappingCache: SubscriptionFeatureMappingCache
private let subscriptionFeatureFlagger: FeatureFlaggerMapping<SubscriptionFeatureFlags>?

@Published public private(set) var availableProducts: [Product] = []
@Published public private(set) var purchasedProductIDs: [String] = []
@Published public private(set) var purchaseQueue: [String] = []

public var areProductsAvailable: Bool { !availableProducts.isEmpty }
public private(set) var currentStorefrontRegion: SubscriptionRegion = .usa
private var transactionUpdates: Task<Void, Never>?
private var storefrontChanges: Task<Void, Never>?

public init() {
public init(subscriptionFeatureMappingCache: SubscriptionFeatureMappingCache,
subscriptionFeatureFlagger: FeatureFlaggerMapping<SubscriptionFeatureFlags>? = nil) {
self.storeSubscriptionConfiguration = DefaultStoreSubscriptionConfiguration()
self.subscriptionFeatureMappingCache = subscriptionFeatureMappingCache
self.subscriptionFeatureFlagger = subscriptionFeatureFlagger
transactionUpdates = observeTransactionUpdates()
storefrontChanges = observeStorefrontChanges()
}
Expand Down Expand Up @@ -109,17 +113,29 @@ public final class DefaultStorePurchaseManager: ObservableObject, StorePurchaseM
return nil
}

let options = [SubscriptionOption(id: monthly.id, cost: .init(displayPrice: monthly.displayPrice, recurrence: "monthly")),
SubscriptionOption(id: yearly.id, cost: .init(displayPrice: yearly.displayPrice, recurrence: "yearly"))]
let features = SubscriptionFeatureName.allCases.map { SubscriptionFeature(name: $0.rawValue) }
let platform: SubscriptionPlatformName

let platform: SubscriptionPlatformName = {
#if os(iOS)
platform = .ios
.ios
#else
platform = .macos
.macos
#endif
return SubscriptionOptions(platform: platform.rawValue,
}()

let options = [SubscriptionOption(id: monthly.id,
cost: .init(displayPrice: monthly.displayPrice, recurrence: "monthly")),
SubscriptionOption(id: yearly.id,
cost: .init(displayPrice: yearly.displayPrice, recurrence: "yearly"))]

let features: [SubscriptionFeature]

if let featureFlagger = subscriptionFeatureFlagger, featureFlagger.isFeatureOn(.isLaunchedROW) || featureFlagger.isFeatureOn(.isLaunchedROWOverride) {
features = await subscriptionFeatureMappingCache.subscriptionFeatures(for: monthly.id).compactMap { SubscriptionFeature(name: $0) }
} else {
let allFeatures: [Entitlement.ProductName] = [.networkProtection, .dataBrokerProtection, .identityTheftRestoration]
features = allFeatures.compactMap { SubscriptionFeature(name: $0) }
}

return SubscriptionOptions(platform: platform,
options: options,
features: features)
}
Expand All @@ -129,11 +145,36 @@ public final class DefaultStorePurchaseManager: ObservableObject, StorePurchaseM
Logger.subscription.info("[StorePurchaseManager] updateAvailableProducts")

do {
let availableProducts = try await Product.products(for: productIdentifiers)
Logger.subscription.info("[StorePurchaseManager] updateAvailableProducts fetched \(availableProducts.count) products")
let storefrontCountryCode: String?
let storefrontRegion: SubscriptionRegion

if let featureFlagger = subscriptionFeatureFlagger, featureFlagger.isFeatureOn(.isLaunchedROW) || featureFlagger.isFeatureOn(.isLaunchedROWOverride) {
if let subscriptionFeatureFlagger, subscriptionFeatureFlagger.isFeatureOn(.usePrivacyProUSARegionOverride) {
storefrontCountryCode = "USA"
} else if let subscriptionFeatureFlagger, subscriptionFeatureFlagger.isFeatureOn(.usePrivacyProROWRegionOverride) {
storefrontCountryCode = "POL"
} else {
storefrontCountryCode = await Storefront.current?.countryCode
}

storefrontRegion = SubscriptionRegion.matchingRegion(for: storefrontCountryCode ?? "USA") ?? .usa // Fallback to USA
} else {
storefrontCountryCode = "USA"
storefrontRegion = .usa
}

self.currentStorefrontRegion = storefrontRegion
let applicableProductIdentifiers = storeSubscriptionConfiguration.subscriptionIdentifiers(for: storefrontRegion)
let availableProducts = try await Product.products(for: applicableProductIdentifiers)
Logger.subscription.info("[StorePurchaseManager] updateAvailableProducts fetched \(availableProducts.count) products for \(storefrontCountryCode ?? "<nil>", privacy: .public)")

if self.availableProducts != availableProducts {
self.availableProducts = availableProducts

// Update cached subscription features mapping
for id in availableProducts.compactMap({ $0.id }) {
_ = await subscriptionFeatureMappingCache.subscriptionFeatures(for: id)
}
}
} catch {
Logger.subscription.error("[StorePurchaseManager] Error: \(String(reflecting: error), privacy: .public)")
Expand Down Expand Up @@ -295,3 +336,36 @@ public final class DefaultStorePurchaseManager: ObservableObject, StorePurchaseM
}
}
}

public extension UserDefaults {

enum Constants {
static let storefrontRegionOverrideKey = "Subscription.debug.storefrontRegionOverride"
static let usaValue = "usa"
static let rowValue = "row"
}

dynamic var storefrontRegionOverride: SubscriptionRegion? {
get {
switch string(forKey: Constants.storefrontRegionOverrideKey) {
case "usa":
return .usa
case "row":
return .restOfWorld
default:
return nil
}
}

set {
switch newValue {
case .usa:
set(Constants.usaValue, forKey: Constants.storefrontRegionOverrideKey)
case .restOfWorld:
set(Constants.rowValue, forKey: Constants.storefrontRegionOverrideKey)
default:
removeObject(forKey: Constants.storefrontRegionOverrideKey)
}
}
}
}
Loading

0 comments on commit 09fd124

Please sign in to comment.