Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Client displays correct subscription #1088

Draft
wants to merge 14 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,6 @@
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"
Expand Down
19 changes: 19 additions & 0 deletions Sources/Subscription/Flows/Models/SubscriptionOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,22 @@
public struct SubscriptionFeature: Encodable, Equatable {
let name: String
}

// TODO: To be removed as we will use ProductNames on FE as well

Check failure on line 51 in Sources/Subscription/Flows/Models/SubscriptionOptions.swift

View workflow job for this annotation

GitHub Actions / Run SwiftLint

TODOs should be resolved (To be removed as we will use P...) (todo)
public extension SubscriptionFeature {
init?(from productName: Entitlement.ProductName) {
switch productName {
case .networkProtection:
name = SubscriptionFeatureName.vpn.rawValue
case .dataBrokerProtection:
name = SubscriptionFeatureName.personalInformationRemoval.rawValue
case .identityTheftRestoration:
name = SubscriptionFeatureName.identityTheftRestoration.rawValue
case .identityTheftRestorationGlobal:
// TODO: Needs to be updated when we have updated SubscriptionFeatureName

Check failure on line 62 in Sources/Subscription/Flows/Models/SubscriptionOptions.swift

View workflow job for this annotation

GitHub Actions / Run SwiftLint

TODOs should be resolved (Needs to be updated when we ha...) (todo)
name = SubscriptionFeatureName.identityTheftRestoration.rawValue
default:
return nil
}
}
}
49 changes: 34 additions & 15 deletions Sources/Subscription/Managers/StorePurchaseManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
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,21 @@
@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

@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) {
self.storeSubscriptionConfiguration = DefaultStoreSubscriptionConfiguration()
self.subscriptionFeatureMappingCache = subscriptionFeatureMappingCache
transactionUpdates = observeTransactionUpdates()
storefrontChanges = observeStorefrontChanges()
}
Expand Down Expand Up @@ -109,16 +110,24 @@
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
}()

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

// TODO: Calculate subscription features based on ProductNames

Check failure on line 126 in Sources/Subscription/Managers/StorePurchaseManager.swift

View workflow job for this annotation

GitHub Actions / Run SwiftLint

TODOs should be resolved (Calculate subscription feature...) (todo)
// let features = SubscriptionFeatureName.allCases.map { SubscriptionFeature(name: $0.rawValue) }
let features: [SubscriptionFeature] = await subscriptionFeatureMappingCache.subscriptionFeatures(for: monthly.id).compactMap { SubscriptionFeature(from: $0) }


Check failure on line 130 in Sources/Subscription/Managers/StorePurchaseManager.swift

View workflow job for this annotation

GitHub Actions / Run SwiftLint

Limit vertical whitespace to a single empty line; currently 2 (vertical_whitespace)
return SubscriptionOptions(platform: platform.rawValue,
options: options,
features: features)
Expand All @@ -129,11 +138,21 @@
Logger.subscription.info("[StorePurchaseManager] updateAvailableProducts")

do {
let availableProducts = try await Product.products(for: productIdentifiers)
Logger.subscription.info("[StorePurchaseManager] updateAvailableProducts fetched \(availableProducts.count) products")
let currentStorefrontCountryCode = await Storefront.current?.countryCode ?? ""
self.currentStorefrontRegion = SubscriptionRegion.matchingRegion(for: currentStorefrontCountryCode) ?? .usa

let applicableProductIdentifiers = storeSubscriptionConfiguration.subscriptionIdentifiers(for: currentStorefrontCountryCode)

let availableProducts = try await Product.products(for: applicableProductIdentifiers)
Logger.subscription.info("[StorePurchaseManager] updateAvailableProducts fetched \(availableProducts.count) products for \(currentStorefrontCountryCode)")

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
18 changes: 18 additions & 0 deletions Sources/Subscription/Managers/SubscriptionManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public protocol SubscriptionManager {
var accountManager: AccountManager { get }
var subscriptionEndpointService: SubscriptionEndpointService { get }
var authEndpointService: AuthEndpointService { get }
var subscriptionFeatureMappingCache: SubscriptionFeatureMappingCache { get }

// Environment
static func loadEnvironmentFrom(userDefaults: UserDefaults) -> SubscriptionEnvironment?
Expand All @@ -35,6 +36,7 @@ public protocol SubscriptionManager {
func loadInitialData()
func refreshCachedSubscriptionAndEntitlements(completion: @escaping (_ isSubscriptionActive: Bool) -> Void)
func url(for type: SubscriptionURL) -> URL
func currentSubscriptionFeatures() async -> [Entitlement.ProductName]
}

/// Single entry point for everything related to Subscription. This manager is disposable, every time something related to the environment changes this need to be recreated.
Expand All @@ -43,18 +45,21 @@ public final class DefaultSubscriptionManager: SubscriptionManager {
public let accountManager: AccountManager
public let subscriptionEndpointService: SubscriptionEndpointService
public let authEndpointService: AuthEndpointService
public let subscriptionFeatureMappingCache: SubscriptionFeatureMappingCache
public let currentEnvironment: SubscriptionEnvironment
public private(set) var canPurchase: Bool = false

public init(storePurchaseManager: StorePurchaseManager? = nil,
accountManager: AccountManager,
subscriptionEndpointService: SubscriptionEndpointService,
authEndpointService: AuthEndpointService,
subscriptionFeatureMappingCache: SubscriptionFeatureMappingCache,
subscriptionEnvironment: SubscriptionEnvironment) {
self._storePurchaseManager = storePurchaseManager
self.accountManager = accountManager
self.subscriptionEndpointService = subscriptionEndpointService
self.authEndpointService = authEndpointService
self.subscriptionFeatureMappingCache = subscriptionFeatureMappingCache
self.currentEnvironment = subscriptionEnvironment
switch currentEnvironment.purchasePlatform {
case .appStore:
Expand Down Expand Up @@ -147,4 +152,17 @@ public final class DefaultSubscriptionManager: SubscriptionManager {
public func url(for type: SubscriptionURL) -> URL {
type.subscriptionURL(environment: currentEnvironment.serviceEnvironment)
}

// MARK: - Current subscription's features

public func currentSubscriptionFeatures() async -> [Entitlement.ProductName] {
guard let token = accountManager.accessToken else { return [] }

switch await subscriptionEndpointService.getSubscription(accessToken: token, cachePolicy: .returnCacheDataElseLoad) {
case .success(let subscription):
return await subscriptionFeatureMappingCache.subscriptionFeatures(for: subscription.productId)
case .failure:
return []
}
}
}
6 changes: 1 addition & 5 deletions Sources/Subscription/NSNotificationName+Subscription.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,7 @@
import Foundation

public extension NSNotification.Name {

static let openPrivateBrowsing = Notification.Name("com.duckduckgo.subscription.open.private-browsing")
static let openPrivateSearch = Notification.Name("com.duckduckgo.subscription.open.private-search")
static let openEmailProtection = Notification.Name("com.duckduckgo.subscription.open.email-protection")
static let openAppTrackingProtection = Notification.Name("com.duckduckgo.subscription.open.app-tracking-protection")

Check failure on line 22 in Sources/Subscription/NSNotificationName+Subscription.swift

View workflow job for this annotation

GitHub Actions / Run SwiftLint

Lines should not have trailing whitespace (trailing_whitespace)
static let openVPN = Notification.Name("com.duckduckgo.subscription.open.vpn")
static let openPersonalInformationRemoval = Notification.Name("com.duckduckgo.subscription.open.personal-information-removal")
static let openIdentityTheftRestoration = Notification.Name("com.duckduckgo.subscription.open.identity-theft-restoration")
Expand Down
123 changes: 123 additions & 0 deletions Sources/Subscription/StoreSubscriptionConfiguration.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
//
// StoreSubscriptionConfiguration.swift
//
// Copyright © 2023 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 Combine

protocol StoreSubscriptionConfiguration {
var allSubscriptionIdentifiers: [String] { get }
func subscriptionIdentifiers(for country: String) -> [String]
}

final class DefaultStoreSubscriptionConfiguration: StoreSubscriptionConfiguration {

private let subscriptions: [StoreSubscriptionDefinition]

convenience init() {
self.init(subscriptionDefinitions: [
// Production shared for iOS and macOS
.init(name: "DuckDuckGo Private Browser",
appIdentifier: "com.duckduckgo.mobile.ios",
environment: .production,
identifiersByCountries: [.usa: ["ddg.privacy.pro.monthly.renews.us",
"ddg.privacy.pro.yearly.renews.us"]]),
// iOS debug Alpha build
.init(name: "DuckDuckGo Alpha",
appIdentifier: "com.duckduckgo.mobile.ios.alpha",
environment: .staging,
identifiersByCountries: [.usa: ["ios.subscription.1month",
"ios.subscription.1year"],
.restOfWorld: ["ios.subscription.1month.row",
"ios.subscription.1year.row"]]),
// macOS debug build
.init(name: "IAP debug - DDG for macOS",
appIdentifier: "com.duckduckgo.macos.browser.debug",
environment: .staging,
identifiersByCountries: [.usa: ["subscription.1month",
"subscription.1year"],
.restOfWorld: ["subscription.1month.row",
"subscription.1year.row"]]),
// macOS review build
.init(name: "IAP review - DDG for macOS",
appIdentifier: "com.duckduckgo.macos.browser.review",
environment: .staging,
identifiersByCountries: [.usa: ["review.subscription.1month",
"review.subscription.1year"],
.restOfWorld: ["review.subscription.1month.row",
"review.subscription.1year.row"]]),

// macOS TestFlight build
.init(name: "DuckDuckGo Sandbox Review",
appIdentifier: "com.duckduckgo.mobile.ios.review",
environment: .staging,
identifiersByCountries: [.usa: ["tf.sandbox.subscription.1month",
"tf.sandbox.subscription.1year"],
.restOfWorld: ["tf.sandbox.subscription.1month.row",
"tf.sandbox.subscription.1year.row"]])
])
}

init(subscriptionDefinitions: [StoreSubscriptionDefinition]) {
self.subscriptions = subscriptionDefinitions
}

var allSubscriptionIdentifiers: [String] {
subscriptions.reduce([], { $0 + $1.allIdentifiers() })
}

func subscriptionIdentifiers(for country: String) -> [String] {
subscriptions.reduce([], { $0 + $1.identifiers(for: country) })
}
}

struct StoreSubscriptionDefinition {
var name: String
var appIdentifier: String
var environment: SubscriptionEnvironment.ServiceEnvironment
var identifiersByCountries: [SubscriptionRegion: [String]]

func allIdentifiers() -> [String] {
identifiersByCountries.values.flatMap { $0 }
}

func identifiers(for country: String) -> [String] {
identifiersByCountries.filter { countries, _ in countries.contains(country) }.flatMap { _, identifiers in identifiers }
}
}

public enum SubscriptionRegion: CaseIterable {
case usa
case restOfWorld

var countryCodes: Set<String> {
switch self {
case .usa:
return Set(["USA"])
case .restOfWorld:
return Set(["POL", "CAN"]) // TODO: Update set of countries (also in ASC)

Check failure on line 112 in Sources/Subscription/StoreSubscriptionConfiguration.swift

View workflow job for this annotation

GitHub Actions / Run SwiftLint

TODOs should be resolved (Update set of countries (also ...) (todo)
}
}

func contains(_ country: String) -> Bool {
countryCodes.contains(country.uppercased())
}

static func matchingRegion(for countryCode: String) -> Self? {
Self.allCases.first { $0.countryCodes.contains(countryCode) }
}
}
Loading
Loading