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

Subscriptions: Implement Caching for Subscription Info #710

Merged
merged 13 commits into from
Mar 11, 2024
9 changes: 5 additions & 4 deletions Sources/Subscription/AccountManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ public class AccountManager: AccountManaging {
do {
try storage.clearAuthenticationState()
try accessTokenStorage.removeAccessToken()
SubscriptionService.signOut()
entitlementsCache.reset()
} catch {
if let error = error as? AccountKeychainAccessError {
Expand Down Expand Up @@ -219,8 +220,8 @@ public class AccountManager: AccountManaging {
case noCachedData
}

public func hasEntitlement(for entitlement: Entitlement.ProductName) async -> Result<Bool, Error> {
switch await fetchEntitlements() {
public func hasEntitlement(for entitlement: Entitlement.ProductName, cachePolicy: CachePolicy = .returnCacheDataElseLoad) async -> Result<Bool, Error> {
switch await fetchEntitlements(cachePolicy: cachePolicy) {
case .success(let entitlements):
return .success(entitlements.compactMap { $0.product }.contains(entitlement))
case .failure(let error):
Expand Down Expand Up @@ -251,9 +252,9 @@ public class AccountManager: AccountManaging {
}
}

public func fetchEntitlements(policy: CachePolicy = .returnCacheDataElseLoad) async -> Result<[Entitlement], Error> {
public func fetchEntitlements(cachePolicy: CachePolicy = .returnCacheDataElseLoad) async -> Result<[Entitlement], Error> {

switch policy {
switch cachePolicy {
case .reloadIgnoringLocalCacheData:
return await fetchRemoteEntitlements()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ public final class AppStorePurchaseFlow {
let accountManager = AccountManager(subscriptionAppGroup: subscriptionAppGroup)
let externalID: String

// Clear the Subscription cache
SubscriptionService.signOut()

// Check for past transactions most recent
switch await AppStoreRestoreFlow.restoreAccountFromPastPurchase(subscriptionAppGroup: subscriptionAppGroup) {
case .success:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ public final class AppStoreRestoreFlow {

var isSubscriptionActive = false

switch await SubscriptionService.getSubscription(accessToken: accessToken) {
switch await SubscriptionService.getSubscription(accessToken: accessToken, cachePolicy: .reloadIgnoringLocalCacheData) {
case .success(let subscription):
isSubscriptionActive = subscription.isActive
case .failure:
Expand Down
1 change: 0 additions & 1 deletion Sources/Subscription/Services/Model/Entitlement.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
import Foundation

public struct Entitlement: Codable, Equatable {
let id: Int
let name: String
public let product: ProductName

Expand Down
56 changes: 46 additions & 10 deletions Sources/Subscription/Services/SubscriptionService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import Common
import Foundation
import Macros

public struct SubscriptionService: APIService {
public final class SubscriptionService: APIService {

public static let session = {
let configuration = URLSessionConfiguration.ephemeral
Expand All @@ -36,24 +36,60 @@ public struct SubscriptionService: APIService {
}
}

// MARK: -
private static let subscriptionCache = UserDefaultsCache<Subscription>(key: UserDefaultsCacheKey.subscription)

public enum CachePolicy {
case reloadIgnoringLocalCacheData
case returnCacheDataElseLoad
case returnCacheDataDontLoad
}

public enum SubscriptionServiceError: Error {
case noCachedData
case apiError(APIServiceError)
}

// MARK: - Subscription fetching with caching

public static func getSubscription(accessToken: String) async -> Result<GetSubscriptionResponse, APIServiceError> {
private static func getRemoteSubscription(accessToken: String) async -> Result<Subscription, SubscriptionServiceError> {
let result: Result<GetSubscriptionResponse, APIServiceError> = await executeAPICall(method: "GET", endpoint: "subscription", headers: makeAuthorizationHeader(for: accessToken))

switch result {
case .success(let response):
cachedGetSubscriptionResponse = response
case .failure:
cachedGetSubscriptionResponse = nil
case .success(let subscriptionResponse):
subscriptionCache.set(subscriptionResponse)
return .success(subscriptionResponse)
case .failure(let error):
return .failure(.apiError(error))
}
}

return result
public static func getSubscription(accessToken: String, cachePolicy: CachePolicy = .returnCacheDataElseLoad) async -> Result<Subscription, SubscriptionServiceError> {

switch cachePolicy {
case .reloadIgnoringLocalCacheData:
return await getRemoteSubscription(accessToken: accessToken)

case .returnCacheDataElseLoad:
if let cachedSubscription = subscriptionCache.get() {
return .success(cachedSubscription)
} else {
return await getRemoteSubscription(accessToken: accessToken)
}

case .returnCacheDataDontLoad:
if let cachedSubscription = subscriptionCache.get() {
return .success(cachedSubscription)
} else {
return .failure(.noCachedData)
}
}
}

public typealias GetSubscriptionResponse = Subscription
public static func signOut() {
subscriptionCache.reset()
}

public static var cachedGetSubscriptionResponse: GetSubscriptionResponse?
public typealias GetSubscriptionResponse = Subscription

// MARK: -

Expand Down
56 changes: 44 additions & 12 deletions Sources/Subscription/UserDefaultsCache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,45 +18,77 @@

import Foundation

public struct UserDefaultsCacheSettings {

// Default expiration interval set to 24 hours
public let defaultExpirationInterval: TimeInterval

public init(defaultExpirationInterval: TimeInterval = 24 * 60 * 60) {
self.defaultExpirationInterval = defaultExpirationInterval
}
}

public enum UserDefaultsCacheKey: String {
case subscriptionEntitlements
case subscriptionEntitlements = "com.duckduckgo.bsk.subscription.entitlements"
case subscription = "com.duckduckgo.bsk.subscription.info"
}

/// A generic UserDefaults cache for storing and retrieving Codable objects.
/// A generic UserDefaults cache for storing and retrieving Codable objects
public class UserDefaultsCache<ObjectType: Codable> {
private var subscriptionAppGroup: String
private lazy var userDefaults: UserDefaults? = UserDefaults(suiteName: subscriptionAppGroup)

private struct CacheObject: Codable {
let expires: Date
let object: ObjectType
}

private var subscriptionAppGroup: String?
private var settings: UserDefaultsCacheSettings
private lazy var userDefaults: UserDefaults? = {
if let appGroup = subscriptionAppGroup {
return UserDefaults(suiteName: appGroup)
} else {
return UserDefaults.standard
}
}()

private let key: UserDefaultsCacheKey

public init(subscriptionAppGroup: String, key: UserDefaultsCacheKey) {
public init(subscriptionAppGroup: String? = nil, key: UserDefaultsCacheKey,
settings: UserDefaultsCacheSettings = UserDefaultsCacheSettings()) {
self.subscriptionAppGroup = subscriptionAppGroup
self.key = key
self.settings = settings
}

public func set(_ object: ObjectType) {
public func set(_ object: ObjectType, expires: Date = Date().addingTimeInterval(UserDefaultsCacheSettings().defaultExpirationInterval)) {
let cacheObject = CacheObject(expires: expires, object: object)
let encoder = JSONEncoder()
do {
let data = try encoder.encode(object)
let data = try encoder.encode(cacheObject)
userDefaults?.set(data, forKey: key.rawValue)
} catch {
assertionFailure("Failed to encode object of type \(ObjectType.self): \(error)")
assertionFailure("Failed to encode CacheObject: \(error)")
}
}

public func get() -> ObjectType? {
guard let data = userDefaults?.data(forKey: key.rawValue) else { return nil }
let decoder = JSONDecoder()
do {
let object = try decoder.decode(ObjectType.self, from: data)
return object
let cacheObject = try decoder.decode(CacheObject.self, from: data)
if cacheObject.expires > Date() {
return cacheObject.object
} else {
reset() // Clear expired data
return nil
}
} catch {
assertionFailure("Failed to decode object of type \(ObjectType.self): \(error)")
assertionFailure("Failed to decode CacheObject: \(error)")
return nil
}
}

public func reset() {
userDefaults?.removeObject(forKey: key.rawValue)
}

}
Loading