Files
purchases-ios/Sources/Caching/DeviceCache.swift
T
NachoSoto a2fc04c09e SubscriberAttribute: converted into struct (#1648)
### Motivation:

Initially I wanted to make `SubscriberAttribute.asDictionary` work using `Codable`. However, this isn't the right refactor because we need the actual raw types to be encoded inside `UserDefaults`.
At least this refactor provides several improvements:

### Changes:

- `SubscriberAttribute` now has value semantics
- ⚠️  `SubscriberAttribute.isSynced` used to be mutable, with reference semantics, which was dangerous
- `SubscriberAttribute: Equatable` implementation is now automatic
- Cleaned up `SubscriberAttribute.asDictionary` implementation, extracting keys into an `enum`
- Moved `Dictionary` -> `SubscriberAttribute` creation to the type itself
- ⚠️ `SubscriberAttribute.init(dictionary:)` returns an `Optional` instead of using force-unwrap, avoiding potential crashes
- Added more serialization / deserialization tests
- Removed `SubscriberAttributesMarshaller` and moved method into `SubscriberAttribute` extension
- Moved `SubscriberAttributeDict` into `SubscriberAttribute.Dictionary`
2022-06-17 10:00:51 -07:00

530 lines
20 KiB
Swift

//
// Copyright RevenueCat Inc. All Rights Reserved.
//
// Licensed under the MIT License (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://opensource.org/licenses/MIT
//
// DeviceCache.swift
//
// Created by Joshua Liebowitz on 7/13/21.
//
import Foundation
// swiftlint:disable file_length
class DeviceCache {
// Thread-safe, but don't call from anywhere inside this class.
var cachedAppUserID: String? {
self.userDefaults.read(Self.cachedAppUserID)
}
var cachedLegacyAppUserID: String? {
self.userDefaults.read {
$0.string(forKey: CacheKeys.legacyGeneratedAppUserDefaults)
}
}
var cachedOfferings: Offerings? { offeringsCachedObject.cachedInstance() }
private let sandboxEnvironmentDetector: SandboxEnvironmentDetector
private let userDefaults: SynchronizedUserDefaults
private let notificationCenter: NotificationCenter
private let offeringsCachedObject: InMemoryCachedObject<Offerings>
/// Keeps track of whether user ID has been set to detect users clearing `UserDefaults`
/// cleared from under the SDK
private var appUserIDHasBeenSet: Bool = false
convenience init(sandboxEnvironmentDetector: SandboxEnvironmentDetector,
userDefaults: UserDefaults = UserDefaults.standard) {
self.init(sandboxEnvironmentDetector: sandboxEnvironmentDetector,
userDefaults: userDefaults,
offeringsCachedObject: nil,
notificationCenter: nil)
}
init(sandboxEnvironmentDetector: SandboxEnvironmentDetector,
userDefaults: UserDefaults = UserDefaults.standard,
offeringsCachedObject: InMemoryCachedObject<Offerings>? = InMemoryCachedObject(),
notificationCenter: NotificationCenter? = NotificationCenter.default) {
self.sandboxEnvironmentDetector = sandboxEnvironmentDetector
self.offeringsCachedObject = offeringsCachedObject ?? InMemoryCachedObject()
self.notificationCenter = notificationCenter ?? NotificationCenter.default
self.userDefaults = .init(userDefaults: userDefaults)
self.appUserIDHasBeenSet = userDefaults.string(forKey: .appUserDefaults) != nil
self.notificationCenter.addObserver(self,
selector: #selector(self.handleUserDefaultsChanged),
name: UserDefaults.didChangeNotification,
object: userDefaults)
}
@objc private func handleUserDefaultsChanged(notification: Notification) {
guard let userDefaults = notification.object as? UserDefaults else {
return
}
if appUserIDHasBeenSet && Self.cachedAppUserID(userDefaults) == nil {
fatalError(Strings.purchase.cached_app_user_id_deleted.description)
}
}
deinit {
notificationCenter.removeObserver(self)
}
// MARK: - appUserID
func cache(appUserID: String) {
self.userDefaults.write {
$0.setValue(appUserID, forKey: CacheKeys.appUserDefaults)
self.appUserIDHasBeenSet = true
}
}
func clearCaches(oldAppUserID: String, andSaveWithNewUserID newUserID: String) {
self.userDefaults.write { userDefaults in
userDefaults.removeObject(forKey: CacheKeys.legacyGeneratedAppUserDefaults)
userDefaults.removeObject(
forKey: CacheKeyBases.customerInfoAppUserDefaults + oldAppUserID
)
// Clear CustomerInfo cache timestamp for oldAppUserID.
userDefaults.removeObject(forKey: CacheKeyBases.customerInfoLastUpdated + oldAppUserID)
// Clear offerings cache.
self.offeringsCachedObject.clearCache()
// Delete attributes if synced for the old app user id.
if Self.unsyncedAttributesByKey(userDefaults, appUserID: oldAppUserID).isEmpty {
var attributes = Self.storedAttributesForAllUsers(userDefaults)
attributes.removeValue(forKey: oldAppUserID)
userDefaults.setValue(attributes, forKey: CacheKeys.subscriberAttributes)
}
// Cache new appUserID.
userDefaults.setValue(newUserID, forKey: CacheKeys.appUserDefaults)
self.appUserIDHasBeenSet = true
}
}
// MARK: - customerInfo
func cachedCustomerInfoData(appUserID: String) -> Data? {
return self.userDefaults.read {
$0.data(forKey: CacheKeyBases.customerInfoAppUserDefaults + appUserID)
}
}
func cache(customerInfo: Data, appUserID: String) {
self.userDefaults.write {
$0.set(customerInfo, forKey: CacheKeyBases.customerInfoAppUserDefaults + appUserID)
Self.setCustomerInfoCacheTimestampToNow($0, appUserID: appUserID)
}
}
func isCustomerInfoCacheStale(appUserID: String, isAppBackgrounded: Bool) -> Bool {
return self.userDefaults.read {
guard let cachesLastUpdated = Self.customerInfoLastUpdated($0, appUserID: appUserID) else {
return true
}
let timeSinceLastCheck = cachesLastUpdated.timeIntervalSinceNow * -1
let cacheDurationInSeconds = self.cacheDurationInSeconds(
isAppBackgrounded: isAppBackgrounded,
isSandbox: self.sandboxEnvironmentDetector.isSandbox
)
return timeSinceLastCheck >= cacheDurationInSeconds
}
}
func clearCachedOfferings() {
self.offeringsCachedObject.clearCache()
}
func clearCustomerInfoCacheTimestamp(appUserID: String) {
self.userDefaults.write {
Self.clearCustomerInfoCacheTimestamp($0, appUserID: appUserID)
}
}
func setCustomerInfoCache(timestamp: Date, appUserID: String) {
self.userDefaults.write {
Self.setCustomerInfoCache($0, timestamp: timestamp, appUserID: appUserID)
}
}
func clearCustomerInfoCache(appUserID: String) {
self.userDefaults.write {
Self.clearCustomerInfoCacheTimestamp($0, appUserID: appUserID)
$0.removeObject(forKey: CacheKeyBases.customerInfoAppUserDefaults + appUserID)
}
}
func setCacheTimestampToNowToPreventConcurrentCustomerInfoUpdates(appUserID: String) {
self.userDefaults.write {
Self.setCustomerInfoCacheTimestampToNow($0, appUserID: appUserID)
}
}
// MARK: - offerings
func cache(offerings: Offerings) {
offeringsCachedObject.cache(instance: offerings)
}
func isOfferingsCacheStale(isAppBackgrounded: Bool) -> Bool {
return offeringsCachedObject.isCacheStale(
durationInSeconds: self.cacheDurationInSeconds(isAppBackgrounded: isAppBackgrounded,
isSandbox: self.sandboxEnvironmentDetector.isSandbox)
)
}
func clearOfferingsCacheTimestamp() {
offeringsCachedObject.clearCacheTimestamp()
}
func setOfferingsCacheTimestampToNow() {
offeringsCachedObject.updateCacheTimestamp(date: Date())
}
// MARK: - subscriber attributes
func store(subscriberAttribute: SubscriberAttribute, appUserID: String) {
store(subscriberAttributesByKey: [subscriberAttribute.key: subscriberAttribute], appUserID: appUserID)
}
func store(subscriberAttributesByKey: [String: SubscriberAttribute], appUserID: String) {
guard !subscriberAttributesByKey.isEmpty else {
return
}
self.userDefaults.write {
var groupedSubscriberAttributes = Self.storedAttributesForAllUsers($0)
var subscriberAttributesForAppUserID = groupedSubscriberAttributes[appUserID] as? [String: Any] ?? [:]
for (key, attributes) in subscriberAttributesByKey {
subscriberAttributesForAppUserID[key] = attributes.asDictionary()
}
groupedSubscriberAttributes[appUserID] = subscriberAttributesForAppUserID
$0.setValue(groupedSubscriberAttributes, forKey: .subscriberAttributes)
}
}
func subscriberAttribute(attributeKey: String, appUserID: String) -> SubscriberAttribute? {
return self.userDefaults.read {
Self.storedSubscriberAttributes($0, appUserID: appUserID)[attributeKey]
}
}
func unsyncedAttributesByKey(appUserID: String) -> [String: SubscriberAttribute] {
return self.userDefaults.read {
Self.unsyncedAttributesByKey($0, appUserID: appUserID)
}
}
func numberOfUnsyncedAttributes(appUserID: String) -> Int {
return self.unsyncedAttributesByKey(appUserID: appUserID).count
}
func cleanupSubscriberAttributes() {
self.userDefaults.write {
Self.migrateSubscriberAttributes($0)
Self.deleteSyncedSubscriberAttributesForOtherUsers($0)
}
}
func unsyncedAttributesForAllUsers() -> [String: [String: SubscriberAttribute]] {
self.userDefaults.read {
let attributesDict = $0.dictionary(forKey: CacheKeys.subscriberAttributes) ?? [:]
var attributes: [String: [String: SubscriberAttribute]] = [:]
for (appUserID, attributesDictForUser) in attributesDict {
var attributesForUser: [String: SubscriberAttribute] = [:]
let attributesDictForUser = attributesDictForUser as? [String: [String: Any]] ?? [:]
for (attributeKey, attributeDict) in attributesDictForUser {
if let attribute = SubscriberAttribute(dictionary: attributeDict), !attribute.isSynced {
attributesForUser[attributeKey] = attribute
}
}
if attributesForUser.count > 0 {
attributes[appUserID] = attributesForUser
}
}
return attributes
}
}
func deleteAttributesIfSynced(appUserID: String) {
self.userDefaults.write {
guard Self.unsyncedAttributesByKey($0, appUserID: appUserID).isEmpty else {
return
}
var groupedAttributes = Self.storedAttributesForAllUsers($0)
let attibutesForAppUserID = groupedAttributes.removeValue(forKey: appUserID)
guard attibutesForAppUserID != nil else {
Logger.warn(Strings.identity.deleting_synced_attributes_none_found)
return
}
$0.setValue(groupedAttributes, forKey: CacheKeys.subscriberAttributes)
}
}
// MARK: - attribution
func latestNetworkAndAdvertisingIdsSent(appUserID: String) -> [String: String] {
return self.userDefaults.read {
let key = CacheKeyBases.attributionDataDefaults + appUserID
let latestNetworkAndAdvertisingIdsSent = $0.object(forKey: key) as? [String: String] ?? [:]
return latestNetworkAndAdvertisingIdsSent
}
}
func set(latestNetworkAndAdvertisingIdsSent: [String: String], appUserID: String) {
self.userDefaults.write {
$0.setValue(latestNetworkAndAdvertisingIdsSent,
forKey: CacheKeyBases.attributionDataDefaults + appUserID)
}
}
func clearLatestNetworkAndAdvertisingIdsSent(appUserID: String) {
self.userDefaults.write {
$0.removeObject(forKey: CacheKeyBases.attributionDataDefaults + appUserID)
}
}
private func cacheDurationInSeconds(isAppBackgrounded: Bool, isSandbox: Bool) -> TimeInterval {
return CacheDuration.duration(status: .init(backgrounded: isAppBackgrounded),
environment: .init(sandbox: isSandbox))
}
// MARK: - Helper functions
fileprivate enum CacheKeys: String {
case legacyGeneratedAppUserDefaults = "com.revenuecat.userdefaults.appUserID"
case appUserDefaults = "com.revenuecat.userdefaults.appUserID.new"
case subscriberAttributes = "com.revenuecat.userdefaults.subscriberAttributes"
}
fileprivate enum CacheKeyBases {
static let keyBase = "com.revenuecat.userdefaults."
static let customerInfoAppUserDefaults = "\(keyBase)purchaserInfo."
static let customerInfoLastUpdated = "\(keyBase)purchaserInfoLastUpdated."
static let legacySubscriberAttributes = "\(keyBase)subscriberAttributes."
static let attributionDataDefaults = "\(keyBase)attribution."
}
}
// All methods that modify or read from the UserDefaults data source but require external mechanisms for ensuring
// mutual exclusion.
private extension DeviceCache {
static func appUserIDsWithLegacyAttributes(_ userDefaults: UserDefaults) -> [String] {
var appUserIDsWithLegacyAttributes: [String] = []
let userDefaultsDict = userDefaults.dictionaryRepresentation()
for key in userDefaultsDict.keys where key.starts(with: CacheKeyBases.keyBase) {
let appUserID = key.replacingOccurrences(of: CacheKeyBases.legacySubscriberAttributes, with: "")
appUserIDsWithLegacyAttributes.append(appUserID)
}
return appUserIDsWithLegacyAttributes
}
static func cachedAppUserID(_ userDefaults: UserDefaults) -> String? {
userDefaults.string(forKey: CacheKeys.appUserDefaults.rawValue)
}
static func storedAttributesForAllUsers(_ userDefaults: UserDefaults) -> [String: Any] {
let attributes = userDefaults.dictionary(forKey: CacheKeys.subscriberAttributes) ?? [:]
return attributes
}
static func customerInfoLastUpdated(
_ userDefaults: UserDefaults,
appUserID: String
) -> Date? {
return userDefaults.object(forKey: CacheKeyBases.customerInfoLastUpdated + appUserID) as? Date
}
static func clearCustomerInfoCacheTimestamp(
_ userDefaults: UserDefaults,
appUserID: String
) {
userDefaults.removeObject(forKey: CacheKeyBases.customerInfoLastUpdated + appUserID)
}
static func unsyncedAttributesByKey(
_ userDefaults: UserDefaults,
appUserID: String
) -> [String: SubscriberAttribute] {
let allSubscriberAttributesByKey = Self.storedSubscriberAttributes(
userDefaults,
appUserID: appUserID
)
var unsyncedAttributesByKey: [String: SubscriberAttribute] = [:]
for attribute in allSubscriberAttributesByKey.values where !attribute.isSynced {
unsyncedAttributesByKey[attribute.key] = attribute
}
return unsyncedAttributesByKey
}
static func setCustomerInfoCache(
_ userDefaults: UserDefaults,
timestamp: Date,
appUserID: String
) {
userDefaults.setValue(timestamp, forKey: CacheKeyBases.customerInfoLastUpdated + appUserID)
}
static func setCustomerInfoCacheTimestampToNow(
_ userDefaults: UserDefaults,
appUserID: String
) {
Self.setCustomerInfoCache(userDefaults, timestamp: Date(), appUserID: appUserID)
}
static func subscriberAttributes(
_ userDefaults: UserDefaults,
appUserID: String
) -> [String: Any] {
return Self.storedAttributesForAllUsers(userDefaults)[appUserID] as? [String: Any] ?? [:]
}
static func storedSubscriberAttributes(
_ userDefaults: UserDefaults,
appUserID: String
) -> [String: SubscriberAttribute] {
let allAttributesObjectsByKey = Self.subscriberAttributes(userDefaults, appUserID: appUserID)
var allSubscriberAttributesByKey: [String: SubscriberAttribute] = [:]
for (key, attributeDict) in allAttributesObjectsByKey {
if let dictionary = attributeDict as? [String: Any],
let attribute = SubscriberAttribute(dictionary: dictionary) {
allSubscriberAttributesByKey[key] = attribute
}
}
return allSubscriberAttributesByKey
}
static func migrateSubscriberAttributes(_ userDefaults: UserDefaults) {
let appUserIDsWithLegacyAttributes = Self.appUserIDsWithLegacyAttributes(userDefaults)
var attributesInNewFormat = userDefaults.dictionary(forKey: CacheKeys.subscriberAttributes) ?? [:]
for appUserID in appUserIDsWithLegacyAttributes {
let legacyAttributes = userDefaults.dictionary(
forKey: CacheKeyBases.legacySubscriberAttributes + appUserID) ?? [:]
let existingAttributes = Self.subscriberAttributes(userDefaults,
appUserID: appUserID)
let allAttributesForUser = legacyAttributes.merging(existingAttributes)
attributesInNewFormat[appUserID] = allAttributesForUser
let legacyAttributesKey = CacheKeyBases.legacySubscriberAttributes + appUserID
userDefaults.removeObject(forKey: legacyAttributesKey)
}
userDefaults.setValue(attributesInNewFormat, forKey: CacheKeys.subscriberAttributes)
}
static func deleteSyncedSubscriberAttributesForOtherUsers(
_ userDefaults: UserDefaults
) {
let allStoredAttributes: [String: [String: Any]]
= userDefaults.dictionary(forKey: CacheKeys.subscriberAttributes)
as? [String: [String: Any]] ?? [:]
var filteredAttributes: [String: Any] = [:]
let currentAppUserID = Self.cachedAppUserID(userDefaults)!
filteredAttributes[currentAppUserID] = allStoredAttributes[currentAppUserID]
for appUserID in allStoredAttributes.keys where appUserID != currentAppUserID {
var unsyncedAttributesForUser: [String: [String: Any]] = [:]
let allStoredAttributesForAppUserID = allStoredAttributes[appUserID] as? [String: [String: Any]] ?? [:]
for (attributeKey, storedAttributesForUser) in allStoredAttributesForAppUserID {
if let attribute = SubscriberAttribute(dictionary: storedAttributesForUser), !attribute.isSynced {
unsyncedAttributesForUser[attributeKey] = storedAttributesForUser
}
}
if !unsyncedAttributesForUser.isEmpty {
filteredAttributes[appUserID] = unsyncedAttributesForUser
}
}
userDefaults.setValue(filteredAttributes, forKey: .subscriberAttributes)
}
}
fileprivate extension UserDefaults {
func setValue(_ value: Any?, forKey key: DeviceCache.CacheKeys) {
self.setValue(value, forKey: key.rawValue)
}
func string(forKey defaultName: DeviceCache.CacheKeys) -> String? {
return self.string(forKey: defaultName.rawValue)
}
func removeObject(forKey defaultName: DeviceCache.CacheKeys) {
removeObject(forKey: defaultName.rawValue)
}
func dictionary(forKey defaultName: DeviceCache.CacheKeys) -> [String: Any]? {
return dictionary(forKey: defaultName.rawValue)
}
}
private extension DeviceCache {
enum CacheDuration {
// swiftlint:disable:next nesting
enum AppStatus {
case foreground
case background
init(backgrounded: Bool) {
self = backgrounded ? .background : .foreground
}
}
// swiftlint:disable:next nesting
enum Environment {
case production
case sandbox
init(sandbox: Bool) {
self = sandbox ? .sandbox : .production
}
}
static func duration(status: AppStatus, environment: Environment) -> TimeInterval {
switch (environment, status) {
case (.production, .foreground): return 60 * 5.0
case (.production, .background): return 60 * 60 * 25.0
case (.sandbox, .foreground): return 60 * 5.0
case (.sandbox, .background): return 60 * 5.0
}
}
}
}
// swiftlint:enable file_length