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

Implement simple Subscription Caching mechanism using UserDefaults #703

Merged
merged 12 commits into from
Mar 8, 2024
57 changes: 51 additions & 6 deletions Sources/Subscription/AccountManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import Common
public extension Notification.Name {
static let accountDidSignIn = Notification.Name("com.duckduckgo.subscription.AccountDidSignIn")
static let accountDidSignOut = Notification.Name("com.duckduckgo.subscription.AccountDidSignOut")
static let entitlementsUpdated = Notification.Name("com.duckduckgo.subscription.EntitlementsDidChange")
}

public protocol AccountManagerKeychainAccessDelegate: AnyObject {
Expand All @@ -36,7 +37,14 @@ public protocol AccountManaging {

public class AccountManager: AccountManaging {

public enum CachePolicy {
case reloadIgnoringLocalCacheData
case returnCacheDataElseLoad
case returnCacheDataDontLoad
}

private let storage: AccountStorage
private let entitlementsCache: UserDefaultsCache<[Entitlement]>
private let accessTokenStorage: SubscriptionTokenStorage

public weak var delegate: AccountManagerKeychainAccessDelegate?
Expand All @@ -47,12 +55,15 @@ public class AccountManager: AccountManaging {

public convenience init(subscriptionAppGroup: String) {
let accessTokenStorage = SubscriptionTokenKeychainStorage(keychainType: .dataProtection(.named(subscriptionAppGroup)))
self.init(accessTokenStorage: accessTokenStorage)
self.init(accessTokenStorage: accessTokenStorage,
entitlementsCache: UserDefaultsCache<[Entitlement]>(subscriptionAppGroup: subscriptionAppGroup, key: UserDefaultsCacheKey.subscriptionEntitlements))
}

public init(storage: AccountStorage = AccountKeychainStorage(),
accessTokenStorage: SubscriptionTokenStorage) {
accessTokenStorage: SubscriptionTokenStorage,
entitlementsCache: UserDefaultsCache<[Entitlement]>) {
self.storage = storage
self.entitlementsCache = entitlementsCache
self.accessTokenStorage = accessTokenStorage
}

Expand Down Expand Up @@ -167,6 +178,7 @@ public class AccountManager: AccountManaging {
do {
try storage.clearAuthenticationState()
try accessTokenStorage.removeAccessToken()
entitlementsCache.reset()
} catch {
if let error = error as? AccountKeychainAccessError {
delegate?.accountManagerKeychainAccessFailed(accessType: .clearAuthenticationData, error: error)
Expand Down Expand Up @@ -202,14 +214,15 @@ public class AccountManager: AccountManaging {

// MARK: -

public enum Entitlement: String {
public enum Entitlement: String, Codable {
case networkProtection = "Network Protection"
case dataBrokerProtection = "Data Broker Protection"
case identityTheftRestoration = "Identity Theft Restoration"
}

public enum EntitlementsError: Error {
case noAccessToken
case noCachedData
}

public func hasEntitlement(for entitlement: Entitlement) async -> Result<Bool, Error> {
Expand All @@ -221,12 +234,21 @@ public class AccountManager: AccountManaging {
}
}

public func fetchEntitlements() async -> Result<[Entitlement], Error> {
guard let accessToken else { return .failure(EntitlementsError.noAccessToken) }
private func fetchRemoteEntitlements() async -> Result<[Entitlement], Error> {
guard let accessToken else {
entitlementsCache.reset()
return .failure(EntitlementsError.noAccessToken)
}

let cachedEntitlements: [Entitlement]? = entitlementsCache.get()

switch await AuthService.validateToken(accessToken: accessToken) {
case .success(let response):
let entitlements = response.account.entitlements.compactMap { Entitlement(rawValue: $0.product) }
let entitlements = response.account.entitlements.compactMap { Entitlement(rawValue: $0.product) }
if entitlements != cachedEntitlements {
entitlementsCache.set(entitlements)
NotificationCenter.default.post(name: .entitlementsUpdated, object: self, userInfo: [UserDefaultsCacheKey.subscriptionEntitlements: entitlements])
}
return .success(entitlements)

case .failure(let error):
Expand All @@ -235,6 +257,29 @@ public class AccountManager: AccountManaging {
}
}

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

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

case .returnCacheDataElseLoad:
if let cachedEntitlements: [Entitlement] = entitlementsCache.get() {
return .success(cachedEntitlements)
} else {
return await fetchRemoteEntitlements()
}

case .returnCacheDataDontLoad:
if let cachedEntitlements: [Entitlement] = entitlementsCache.get() {
return .success(cachedEntitlements)
} else {
return .failure(EntitlementsError.noCachedData)
}
}

}

public func exchangeAuthTokenToAccessToken(_ authToken: String) async -> Result<String, Error> {
switch await AuthService.getAccessToken(token: authToken) {
case .success(let response):
Expand Down
2 changes: 1 addition & 1 deletion Sources/Subscription/Services/AuthService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ public struct AuthService: APIService {
}
}

struct Entitlement: Decodable {
struct Entitlement: Decodable, Equatable {
let id: Int
let name: String
let product: String
Expand Down
62 changes: 62 additions & 0 deletions Sources/Subscription/UserDefaultsCache.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
//
// UserDefaultsCache.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

public enum UserDefaultsCacheKey: String {
case subscriptionEntitlements
}

/// 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 let key: UserDefaultsCacheKey

public init(subscriptionAppGroup: String, key: UserDefaultsCacheKey) {
self.subscriptionAppGroup = subscriptionAppGroup
self.key = key
}

public func set(_ object: ObjectType) {
let encoder = JSONEncoder()
do {
let data = try encoder.encode(object)
userDefaults?.set(data, forKey: key.rawValue)
} catch {
assertionFailure("Failed to encode object of type \(ObjectType.self): \(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
} catch {
assertionFailure("Failed to decode object of type \(ObjectType.self): \(error)")
return nil
}
}

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

}
Loading