Skip to content

Commit

Permalink
Merge branch 'main' into fcappelli/subscription_oauth_api_v2
Browse files Browse the repository at this point in the history
# Conflicts:
#	Sources/Networking/v2/Extensions/HTTPURLResponse+HTTPStatusCode.swift
#	Sources/Subscription/API/SubscriptionEndpointService.swift
#	Sources/Subscription/Flows/Stripe/StripePurchaseFlow.swift
#	Sources/Subscription/Managers/StorePurchaseManager.swift
#	Sources/Subscription/Managers/SubscriptionManager.swift
#	Sources/SubscriptionTestingUtilities/APIs/SubscriptionEndpointServiceMock.swift
#	Sources/SubscriptionTestingUtilities/Managers/SubscriptionManagerMock.swift
#	Sources/SubscriptionTestingUtilities/SubscriptionCookie/SubscriptionCookieManagerMock.swift
#	Tests/SubscriptionTests/Managers/SubscriptionManagerTests.swift
#	Tests/SubscriptionTests/SubscriptionCookie/SubscriptionCookieManagerTests.swift
  • Loading branch information
federicocappelli committed Dec 2, 2024
2 parents 97ddc9c + 09fd124 commit 97e986e
Show file tree
Hide file tree
Showing 20 changed files with 625 additions and 97 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/Networking/OAuth/OAuthTokens.swift
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ public enum SubscriptionEntitlement: String, 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
20 changes: 20 additions & 0 deletions Sources/Subscription/API/SubscriptionEndpointService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ public struct ConfirmPurchaseResponse: Codable, Equatable {
public let subscription: PrivacyProSubscription
}

public struct GetSubscriptionFeaturesResponse: Decodable {
public let features: [SubscriptionEntitlement]
}

public enum SubscriptionEndpointServiceError: Error {
case noData
case invalidRequest
Expand All @@ -55,6 +59,7 @@ public protocol SubscriptionEndpointService {
func getSubscription(accessToken: String, cachePolicy: SubscriptionCachePolicy) async throws -> PrivacyProSubscription
func clearSubscription()
func getProducts() async throws -> [GetProductsItem]
func getSubscriptionFeatures(for subscriptionID: String) async throws -> GetSubscriptionFeaturesResponse
func getCustomerPortalURL(accessToken: String, externalID: String) async throws -> GetCustomerPortalURLResponse
func confirmPurchase(accessToken: String, signature: String) async throws -> ConfirmPurchaseResponse
}
Expand Down Expand Up @@ -197,4 +202,19 @@ public struct DefaultSubscriptionEndpointService: SubscriptionEndpointService {
throw SubscriptionEndpointServiceError.invalidResponseCode(statusCode)
}
}

public func getSubscriptionFeatures(for subscriptionID: String) async throws -> GetSubscriptionFeaturesResponse {
Logger.subscriptionEndpointService.log("Getting subscription features")
guard let request = SubscriptionRequest.subscriptionFeatures(baseURL: baseURL, subscriptionID: subscriptionID) else {
throw SubscriptionEndpointServiceError.invalidRequest
}
let response = try await apiService.fetch(request: request.apiRequest)
let statusCode = response.httpResponse.httpStatus
if statusCode.isSuccess {
Logger.subscriptionEndpointService.log("\(#function) request completed")
return try response.decodeBody()
} else {
throw SubscriptionEndpointServiceError.invalidResponseCode(statusCode)
}
}
}
9 changes: 9 additions & 0 deletions Sources/Subscription/API/SubscriptionRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,4 +73,13 @@ struct SubscriptionRequest {
}
return SubscriptionRequest(apiRequest: request)
}

static func subscriptionFeatures(baseURL: URL, subscriptionID: String) -> SubscriptionRequest? {
let path = "/products/\(subscriptionID)/features"
guard let request = APIRequestV2(url: baseURL.appendingPathComponent(path),
method: .get) else {
return nil
}
return SubscriptionRequest(apiRequest: request)
}
}
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
}
}
}
26 changes: 18 additions & 8 deletions Sources/Subscription/Flows/Models/SubscriptionOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,23 +17,37 @@
//

import Foundation
import Networking

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

public static var empty: SubscriptionOptions {
let features = SubscriptionFeatureName.allCases.map { SubscriptionFeature(name: $0.rawValue) }
let features: [SubscriptionEntitlement] = [.networkProtection,
.dataBrokerProtection,
.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 @@ -43,7 +57,3 @@ struct SubscriptionOptionCost: Encodable, Equatable {
let displayPrice: String
let recurrence: String
}

public struct SubscriptionFeature: Encodable, Equatable {
let name: String
}
8 changes: 6 additions & 2 deletions Sources/Subscription/Flows/Stripe/StripePurchaseFlow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,12 @@ public final class DefaultStripePurchaseFlow: StripePurchaseFlow {
return SubscriptionOption(id: $0.productId, cost: cost)
}

let features = SubscriptionFeatureName.allCases.map { SubscriptionFeature(name: $0.rawValue) }
return .success(SubscriptionOptions(platform: SubscriptionPlatformName.stripe.rawValue, options: options, features: features))
let features: [SubscriptionEntitlement] = [.networkProtection,
.dataBrokerProtection,
.identityTheftRestoration]
return .success(SubscriptionOptions(platform: SubscriptionPlatformName.stripe,
options: options,
features: features))
}

public func prepareSubscriptionPurchase(emailAccessToken: String?) async -> Result<PurchaseUpdate, StripePurchaseFlowError> {
Expand Down
101 changes: 85 additions & 16 deletions Sources/Subscription/Managers/StorePurchaseManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import Foundation
import StoreKit
import os.log
import Networking

public enum StoreError: Error {
case failedVerification
Expand All @@ -41,6 +42,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 +58,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 @@ -108,17 +113,23 @@ 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: [SubscriptionEntitlement]
if let featureFlagger = subscriptionFeatureFlagger, featureFlagger.isFeatureOn(.isLaunchedROW) || featureFlagger.isFeatureOn(.isLaunchedROWOverride) {
features = await subscriptionFeatureMappingCache.subscriptionFeatures(for: monthly.id)
} else {
features = [.networkProtection, .dataBrokerProtection, .identityTheftRestoration]
}
return SubscriptionOptions(platform: platform,
options: options,
features: features)
}
Expand All @@ -128,11 +139,36 @@ public final class DefaultStorePurchaseManager: ObservableObject, StorePurchaseM
Logger.subscriptionStorePurchaseManager.log("Update available products")

do {
let availableProducts = try await Product.products(for: productIdentifiers)
Logger.subscriptionStorePurchaseManager.log("\(availableProducts.count) products available")
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.subscriptionStorePurchaseManager.error("Failed to fetch available products: \(String(reflecting: error), privacy: .public)")
Expand Down Expand Up @@ -286,3 +322,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 97e986e

Please sign in to comment.