Files
purchases-ios/Sources/Caching/DeviceCache.swift
T
Jacob RakidzichandAntonio Pallares f033ddc398 Fix Crashes: Move large object cacheing off of user defaults to file storage (#5652)
* move device cache to file cache for potentially large data sets

* Fix vibe coded implementation and test it

* Update device cache tests

* bring back some of the old functionality

* revert accidental deletion

* use the static variable

* be kind and clean up after your(my)self.

* Move from caches to docs dir

* simplify cache by returning the decoded object

* remove update function and scope read and write to internal functions

* Potential fix for CI issues?

* fix tests

* Update Sources/Caching/DeviceCache.swift

Co-authored-by: Antonio Pallares <ajpallares@users.noreply.github.com>

* Update Sources/Caching/DeviceCache.swift

Co-authored-by: Antonio Pallares <ajpallares@users.noreply.github.com>

* scope adjustment

* requested changes

* fix test

---------

Co-authored-by: Antonio Pallares <ajpallares@users.noreply.github.com>
2025-11-18 10:02:28 -06:00

789 lines
30 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 type_body_length
class DeviceCache {
private static let defaultBasePath = "RevenueCat"
var cachedAppUserID: String? { return self._cachedAppUserID.value }
var cachedLegacyAppUserID: String? { return self._cachedLegacyAppUserID.value }
var cachedOfferings: Offerings? { self.offeringsCachedObject.cachedInstance }
private let systemInfo: SystemInfo
private let userDefaults: SynchronizedUserDefaults
private let largeItemCache: SynchronizedLargeItemCache
private let offeringsCachedObject: InMemoryCachedObject<Offerings>
private let _cachedAppUserID: Atomic<String?>
private let _cachedLegacyAppUserID: Atomic<String?>
private var userDefaultsObserver: NSObjectProtocol?
private var offeringsCachePreferredLocales: [String] = []
private let cacheURL: URL?
init(systemInfo: SystemInfo,
userDefaults: UserDefaults,
fileManager: LargeItemCacheType = FileManager.default,
offeringsCachedObject: InMemoryCachedObject<Offerings> = .init()) {
self.offeringsCachedObject = offeringsCachedObject
self.systemInfo = systemInfo
self.userDefaults = .init(userDefaults: userDefaults)
self._cachedAppUserID = .init(userDefaults.string(forKey: CacheKeys.appUserDefaults))
self._cachedLegacyAppUserID = .init(userDefaults.string(forKey: CacheKeys.legacyGeneratedAppUserDefaults))
self.cacheURL = fileManager.createDocumentDirectoryIfNeeded(basePath: Self.defaultBasePath)
self.largeItemCache = .init(cache: fileManager, basePath: Self.defaultBasePath)
Logger.verbose(Strings.purchase.device_cache_init(self))
}
deinit {
Logger.verbose(Strings.purchase.device_cache_deinit(self))
}
// MARK: - generic methods
private func value<Key: DeviceCacheKeyType, Value: Codable>(for key: Key) -> Value? {
// Large data used to be stored in the user defaults and resulted in crashes, we need to ensure that
// we are cleaning out that data
userDefaults.write { defaults in
defaults.removeObject(forKey: key)
}
return self.largeItemCache.value(forKey: key)
}
// MARK: - appUserID
func cache(appUserID: String) {
self.userDefaults.write {
$0.set(appUserID, forKey: CacheKeys.appUserDefaults)
}
self._cachedAppUserID.value = appUserID
}
func clearCaches(oldAppUserID: String, andSaveWithNewUserID newUserID: String) {
self.userDefaults.write { userDefaults in
userDefaults.removeObject(forKey: CacheKeys.legacyGeneratedAppUserDefaults)
userDefaults.removeObject(
forKey: CacheKey.customerInfo(oldAppUserID)
)
// Clear CustomerInfo cache timestamp for oldAppUserID.
userDefaults.removeObject(forKey: CacheKey.customerInfoLastUpdated(oldAppUserID))
// Clear offerings cache.
self.offeringsCachedObject.clearCache()
// Remove offerings from UserDefaults to clear any pre-existing data from
// before the migration to largeItemCache
userDefaults.removeObject(forKey: CacheKey.offerings(oldAppUserID))
// Clear virtual currencies cache
userDefaults.removeObject(forKey: CacheKey.virtualCurrencies(oldAppUserID))
// 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.set(attributes, forKey: CacheKeys.subscriberAttributes)
}
// Cache new appUserID.
userDefaults.set(newUserID, forKey: CacheKeys.appUserDefaults)
self._cachedAppUserID.value = newUserID
self._cachedLegacyAppUserID.value = nil
}
// Clear offerings cache from large item cache
self.largeItemCache.removeObject(forKey: CacheKey.offerings(oldAppUserID))
}
// MARK: - CustomerInfo
func cachedCustomerInfoData(appUserID: String) -> Data? {
return self.userDefaults.read {
$0.data(forKey: CacheKey.customerInfo(appUserID))
}
}
func cache(customerInfo: Data, appUserID: String) {
self.userDefaults.write {
$0.set(customerInfo, forKey: CacheKey.customerInfo(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.systemInfo.isSandbox
)
return timeSinceLastCheck >= cacheDurationInSeconds
}
}
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: CacheKey.customerInfo(appUserID))
}
}
// MARK: - Offerings
func cachedOfferingsContents(appUserID: String) -> Offerings.Contents? {
return self.value(for: CacheKey.offerings(appUserID))
}
func cache(offerings: Offerings, preferredLocales: [String], appUserID: String) {
// We can't get the preferred locales from the `systemInfo` object because they may change
// during the get offerings request, before this cache method gets called.
// For the cache we need the preferred locales that were used in the request.
self.cacheInMemory(offerings: offerings)
self.offeringsCachePreferredLocales = preferredLocales
self.largeItemCache.set(codable: offerings.contents, forKey: CacheKey.offerings(appUserID))
}
func cacheInMemory(offerings: Offerings) {
self.offeringsCachedObject.cache(instance: offerings)
}
func clearOfferingsCache(appUserID: String) {
self.offeringsCachedObject.clearCache()
self.offeringsCachePreferredLocales = []
self.largeItemCache.removeObject(forKey: CacheKey.offerings(appUserID))
}
func isOfferingsCacheStale(isAppBackgrounded: Bool) -> Bool {
// Time-based staleness, or
return self.offeringsCachedObject.isCacheStale(
durationInSeconds: self.cacheDurationInSeconds(isAppBackgrounded: isAppBackgrounded,
isSandbox: self.systemInfo.isSandbox)
) ||
// Locale-based staleness
self.offeringsCachePreferredLocales != self.systemInfo.preferredLocales
}
func forceOfferingsCacheStale() {
self.offeringsCachedObject.clearCacheTimestamp()
self.offeringsCachePreferredLocales = []
}
func offeringsCacheStatus(isAppBackgrounded: Bool) -> CacheStatus {
if self.offeringsCachedObject.cachedInstance == nil {
return .notFound
} else if self.isOfferingsCacheStale(isAppBackgrounded: isAppBackgrounded) {
return .stale
} else {
return .valid
}
}
// 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 {
Self.store($0, subscriberAttributesByKey: subscriberAttributesByKey, appUserID: appUserID)
}
}
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
}
Self.deleteAllAttributes($0, appUserID: appUserID)
}
}
func copySubscriberAttributes(oldAppUserID: String, newAppUserID: String) {
self.userDefaults.write {
let unsyncedAttributesToCopy = Self.unsyncedAttributesByKey($0, appUserID: oldAppUserID)
guard !unsyncedAttributesToCopy.isEmpty else {
return
}
Logger.info(Strings.attribution.copying_attributes(oldAppUserID: oldAppUserID, newAppUserID: newAppUserID))
Self.store($0, subscriberAttributesByKey: unsyncedAttributesToCopy, appUserID: newAppUserID)
Self.deleteAllAttributes($0, appUserID: oldAppUserID)
}
}
// MARK: - attribution
func latestAdvertisingIdsByNetworkSent(appUserID: String) -> [AttributionNetwork: String] {
return self.userDefaults.read {
let key = CacheKey.attributionDataDefaults(appUserID)
let latestAdvertisingIdsByRawNetworkSent = $0.object(forKey: key.rawValue) as? [String: String] ?? [:]
let latestSent: [AttributionNetwork: String] =
latestAdvertisingIdsByRawNetworkSent.compactMapKeys { networkKey in
guard let networkRawValue = Int(networkKey),
let attributionNetwork = AttributionNetwork(rawValue: networkRawValue) else {
Logger.error(
Strings.attribution.latest_attribution_sent_user_defaults_invalid(
networkKey: networkKey
)
)
return nil
}
return attributionNetwork
}
return latestSent
}
}
func set(latestAdvertisingIdsByNetworkSent: [AttributionNetwork: String], appUserID: String) {
self.userDefaults.write {
let latestAdIdsByRawNetworkStringSent = latestAdvertisingIdsByNetworkSent.mapKeys { String($0.rawValue) }
$0.set(latestAdIdsByRawNetworkStringSent,
forKey: CacheKey.attributionDataDefaults(appUserID))
}
}
func clearLatestNetworkAndAdvertisingIdsSent(appUserID: String) {
self.userDefaults.write {
$0.removeObject(forKey: CacheKey.attributionDataDefaults(appUserID))
}
}
private func cacheDurationInSeconds(isAppBackgrounded: Bool, isSandbox: Bool) -> TimeInterval {
return CacheDuration.duration(status: .init(backgrounded: isAppBackgrounded),
environment: .init(sandbox: isSandbox))
}
// MARK: - Products Entitlements
var isProductEntitlementMappingCacheStale: Bool {
return self.userDefaults.read {
guard let cacheLastUpdated = Self.productEntitlementMappingLastUpdated($0) else {
return true
}
let cacheAge = Date().timeIntervalSince(cacheLastUpdated)
return cacheAge > DeviceCache.productEntitlementMappingCacheDuration.seconds
}
}
func store(productEntitlementMapping: ProductEntitlementMapping) {
if self.largeItemCache.set(
codable: productEntitlementMapping,
forKey: CacheKeys.productEntitlementMapping
) {
self.userDefaults.write {
$0.set(Date(), forKey: CacheKeys.productEntitlementMappingLastUpdated)
}
}
}
var cachedProductEntitlementMapping: ProductEntitlementMapping? {
return self.value(for: CacheKeys.productEntitlementMapping)
}
// MARK: - StoreKit 2
private let cachedSyncedSK2ObserverModeTransactionIDsLock = Lock(.nonRecursive)
func registerNewSyncedSK2ObserverModeTransactionIDs(_ ids: [UInt64]) {
cachedSyncedSK2ObserverModeTransactionIDsLock.perform {
var transactionIDs = self.userDefaults.read { userDefaults in
userDefaults.array(
forKey: CacheKey.syncedSK2ObserverModeTransactionIDs.rawValue) as? [UInt64]
} ?? []
transactionIDs.append(contentsOf: ids)
self.userDefaults.write {
$0.set(
transactionIDs,
forKey: CacheKey.syncedSK2ObserverModeTransactionIDs
)
}
}
}
func cachedSyncedSK2ObserverModeTransactionIDs() -> [UInt64] {
cachedSyncedSK2ObserverModeTransactionIDsLock.perform {
return self.userDefaults.read { userDefaults in
userDefaults.array(
forKey: CacheKey.syncedSK2ObserverModeTransactionIDs.rawValue) as? [UInt64] ?? []
}
}
}
// MARK: - Virtual Currencies
func cache(
virtualCurrencies: Data,
appUserID: String
) {
self.userDefaults.write {
$0.set(virtualCurrencies, forKey: CacheKey.virtualCurrencies(appUserID))
Self.setVirtualCurrenciesCacheLastUpdatedTimestampToNow($0, appUserID: appUserID)
}
}
func cachedVirtualCurrenciesData(forAppUserID appUserID: String) -> Data? {
return self.userDefaults.read {
$0.data(forKey: CacheKey.virtualCurrencies(appUserID))
}
}
func isVirtualCurrenciesCacheStale(appUserID: String, isAppBackgrounded: Bool) -> Bool {
return self.userDefaults.read {
guard let cachesLastUpdated = Self.virtualCurrenciesLastUpdated($0, appUserID: appUserID) else {
return true
}
let timeSinceLastCheck = cachesLastUpdated.timeIntervalSinceNow * -1
let cacheDurationInSeconds = self.cacheDurationInSeconds(
isAppBackgrounded: isAppBackgrounded,
isSandbox: self.systemInfo.isSandbox
)
return timeSinceLastCheck >= cacheDurationInSeconds
}
}
func clearVirtualCurrenciesCache(appUserID: String) {
self.userDefaults.write {
Self.clearVirtualCurrenciesCacheLastUpdatedTimestamp($0, appUserID: appUserID)
$0.removeObject(forKey: CacheKey.virtualCurrencies(appUserID))
}
}
func clearVirtualCurrenciesCacheLastUpdatedTimestamp(appUserID: String) {
self.userDefaults.write {
Self.clearVirtualCurrenciesCacheLastUpdatedTimestamp($0, appUserID: appUserID)
}
}
func setVirtualCurrenciesCacheLastUpdatedTimestamp(
timestamp: Date,
appUserID: String
) {
self.userDefaults.write {
Self.setVirtualCurrenciesCacheLastUpdatedTimestamp($0, timestamp: timestamp, appUserID: appUserID)
}
}
// MARK: - Helper functions
internal enum CacheKeys: String, DeviceCacheKeyType {
case legacyGeneratedAppUserDefaults = "com.revenuecat.userdefaults.appUserID"
case appUserDefaults = "com.revenuecat.userdefaults.appUserID.new"
case subscriberAttributes = "com.revenuecat.userdefaults.subscriberAttributes"
case productEntitlementMapping = "com.revenuecat.userdefaults.productEntitlementMapping"
case productEntitlementMappingLastUpdated = "com.revenuecat.userdefaults.productEntitlementMappingLastUpdated"
}
fileprivate enum CacheKey: DeviceCacheKeyType {
static let base = "com.revenuecat.userdefaults."
static let legacySubscriberAttributesBase = "\(Self.base)subscriberAttributes."
case customerInfo(String)
case customerInfoLastUpdated(String)
case offerings(String)
case legacySubscriberAttributes(String)
case attributionDataDefaults(String)
case syncedSK2ObserverModeTransactionIDs
case virtualCurrencies(String)
case virtualCurrenciesLastUpdated(String)
var rawValue: String {
switch self {
case let .customerInfo(userID): return "\(Self.base)purchaserInfo.\(userID)"
case let .customerInfoLastUpdated(userID): return "\(Self.base)purchaserInfoLastUpdated.\(userID)"
case let .offerings(userID): return "\(Self.base)offerings.\(userID)"
case let .legacySubscriberAttributes(userID): return "\(Self.legacySubscriberAttributesBase)\(userID)"
case let .attributionDataDefaults(userID): return "\(Self.base)attribution.\(userID)"
case .syncedSK2ObserverModeTransactionIDs:
return "\(Self.base)syncedSK2ObserverModeTransactionIDs"
case let .virtualCurrencies(userID): return "\(Self.base)virtualCurrencies.\(userID)"
case let .virtualCurrenciesLastUpdated(userID): return "\(Self.base)virtualCurrenciesLastUpdated.\(userID)"
}
}
}
}
// @unchecked because:
// - Class is not `final` (it's mocked). This implicitly makes subclasses `Sendable` even if they're not thread-safe.
extension DeviceCache: @unchecked Sendable {}
// MARK: -
extension DeviceCache: ProductEntitlementMappingFetcher {
var productEntitlementMapping: ProductEntitlementMapping? {
return self.cachedProductEntitlementMapping
}
}
// MARK: - Private
// 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: CacheKey.base) {
let appUserID = key.replacingOccurrences(of: CacheKey.legacySubscriberAttributesBase, with: "")
appUserIDsWithLegacyAttributes.append(appUserID)
}
return appUserIDsWithLegacyAttributes
}
static func cachedAppUserID(_ userDefaults: UserDefaults) -> String? {
userDefaults.string(forKey: CacheKeys.appUserDefaults)
}
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.date(forKey: CacheKey.customerInfoLastUpdated(appUserID))
}
static func clearCustomerInfoCacheTimestamp(
_ userDefaults: UserDefaults,
appUserID: String
) {
userDefaults.removeObject(forKey: CacheKey.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 store(
_ userDefaults: UserDefaults,
subscriberAttributesByKey: [String: SubscriberAttribute],
appUserID: String
) {
var groupedSubscriberAttributes = Self.storedAttributesForAllUsers(userDefaults)
var subscriberAttributesForAppUserID = groupedSubscriberAttributes[appUserID] as? [String: Any] ?? [:]
for (key, attributes) in subscriberAttributesByKey {
subscriberAttributesForAppUserID[key] = attributes.asDictionary()
}
groupedSubscriberAttributes[appUserID] = subscriberAttributesForAppUserID
userDefaults.set(groupedSubscriberAttributes, forKey: CacheKeys.subscriberAttributes)
}
static func deleteAllAttributes(
_ userDefaults: UserDefaults,
appUserID: String
) {
var groupedAttributes = Self.storedAttributesForAllUsers(userDefaults)
let attributesForAppUserID = groupedAttributes.removeValue(forKey: appUserID)
guard attributesForAppUserID != nil else {
Logger.warn(Strings.identity.deleting_attributes_none_found)
return
}
userDefaults.set(groupedAttributes, forKey: CacheKeys.subscriberAttributes)
}
static func setCustomerInfoCache(
_ userDefaults: UserDefaults,
timestamp: Date,
appUserID: String
) {
userDefaults.set(timestamp, forKey: CacheKey.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: CacheKey.legacySubscriberAttributes(appUserID)) ?? [:]
let existingAttributes = Self.subscriberAttributes(userDefaults,
appUserID: appUserID)
let allAttributesForUser = legacyAttributes.merging(existingAttributes)
attributesInNewFormat[appUserID] = allAttributesForUser
userDefaults.removeObject(forKey: CacheKey.legacySubscriberAttributes(appUserID))
}
userDefaults.set(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] = [:]
// swiftlint:disable:next force_unwrapping
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.set(filteredAttributes, forKey: CacheKeys.subscriberAttributes)
}
static func productEntitlementMappingLastUpdated(_ userDefaults: UserDefaults) -> Date? {
return userDefaults.date(forKey: CacheKeys.productEntitlementMappingLastUpdated)
}
static func virtualCurrenciesLastUpdated(
_ userDefaults: UserDefaults,
appUserID: String
) -> Date? {
return userDefaults.date(forKey: CacheKey.virtualCurrenciesLastUpdated(appUserID))
}
static func setVirtualCurrenciesCacheLastUpdatedTimestamp(
_ userDefaults: UserDefaults,
timestamp: Date,
appUserID: String
) {
userDefaults.set(timestamp, forKey: CacheKey.virtualCurrenciesLastUpdated(appUserID))
}
static func setVirtualCurrenciesCacheLastUpdatedTimestampToNow(
_ userDefaults: UserDefaults,
appUserID: String
) {
Self.setVirtualCurrenciesCacheLastUpdatedTimestamp(userDefaults, timestamp: Date(), appUserID: appUserID)
}
static func clearVirtualCurrenciesCacheLastUpdatedTimestamp(
_ userDefaults: UserDefaults,
appUserID: String
) {
userDefaults.removeObject(forKey: CacheKey.virtualCurrenciesLastUpdated(appUserID))
}
}
fileprivate extension UserDefaults {
func set(_ value: Any?, forKey key: DeviceCacheKeyType) {
self.set(value, forKey: key.rawValue)
}
func string(forKey defaultName: DeviceCacheKeyType) -> String? {
return self.string(forKey: defaultName.rawValue)
}
func removeObject(forKey defaultName: DeviceCacheKeyType) {
self.removeObject(forKey: defaultName.rawValue)
}
func dictionary(forKey defaultName: DeviceCacheKeyType) -> [String: Any]? {
return self.dictionary(forKey: defaultName.rawValue)
}
func date(forKey defaultName: DeviceCacheKeyType) -> Date? {
return self.object(forKey: defaultName.rawValue) as? Date
}
func data(forKey key: DeviceCacheKeyType) -> Data? {
return self.data(forKey: key.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
}
}
}
static let productEntitlementMappingCacheDuration: DispatchTimeInterval = .hours(25)
}
protocol DeviceCacheKeyType {
var rawValue: String { get }
}