Files
purchases-ios/Sources/Caching/DeviceCache.swift
T
Antonio Pallares 550579d408 Fix: Clear in-memory offerings cache on locale override to prevent stale paywall data (#6446)
* Invalidate offerings cache on locale override to prevent stale paywall data

When `overridePreferredUILocale` is called, the cached offerings may contain
localized data for the old locale. Without clearing the cache, a subsequently
presented paywall could render stale data via the stale-while-revalidate path.

Made-with: Cursor

* Ensure cache invalidation on locale change even when rate-limited

Move `invalidateCachedOfferings` outside the rate limiter guard so the
offerings cache is always cleared when the locale changes. The rate
limiter now only gates the network re-fetch, not the cache invalidation.

Also adds unit tests for overridePreferredUILocale including the
rate-limited scenario.

Made-with: Cursor

* Gate cache invalidation behind rate limiter to prevent abuse

Move invalidateCachedOfferings back inside the rate limiter guard so
that rapid locale alternation cannot bypass rate limiting by forcing
cache misses on every call.

Made-with: Cursor

* Restore original comment in overridePreferredUILocale

Made-with: Cursor

* Only invalidate in-memory offerings cache on locale override

Replace invalidateCachedOfferings (which clears in-memory, disk, and
locale caches) with a lighter invalidateInMemoryCache that only clears
the in-memory cached object. This avoids unnecessarily wiping disk
cache when the locale changes. Also removes fetchCurrent: true since
the in-memory invalidation already ensures a network fetch.

Made-with: Cursor

* Add warning log when overridePreferredUILocale is rate-limited

Made-with: Cursor

* Rename invalidateInMemoryCache to clearInMemoryOfferingsCache

Made-with: Cursor

* Match Android rate limit log: use debug level with dynamic parameters

Made-with: Cursor
2026-03-12 11:54:09 +00:00

976 lines
38 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
/// Caches data in `UserDefaults` and in-memory caches.
///
/// ## Thread Safety
///
/// `UserDefaults` is thread-safe for individual read and write operations according to Apple's documentation.
/// This means simple get/set operations on single keys are safe without additional synchronization.
///
/// However, **read-modify-write (RMW) operations are NOT atomic** and can have race conditions when
/// accessed from multiple threads simultaneously. This class uses two different `UserDefaults` wrappers:
///
/// ### Lock-free wrapper (`userDefaults`)
/// Used for simple read/write operations that don't require atomicity. This avoids deadlocks that can occur
/// when the main thread waits for a lock while a background thread holds it and writes to UserDefaults
/// (which posts `didChangeNotification` to main queue).
///
/// ### Locking wrapper (`lockingUserDefaults`)
/// Used for **subscriber attribute write operations** which require atomic read-modify-write:
/// - `store(subscriberAttributesByKey:)`, `deleteAttributesIfSynced()`, `copySubscriberAttributes()`,
/// `cleanupSubscriberAttributes()`, `clearCaches()`
///
/// Read-only subscriber attribute operations (`subscriberAttribute()`, `unsyncedAttributesByKey()`,
/// `unsyncedAttributesForAllUsers()`) use the lock-free wrapper since they don't modify data.
///
/// The locking wrapper has a potential deadlock risk if called from the main thread while a background thread
/// holds the lock. This will be addressed in a future PR by refactoring subscriber attributes to use
/// individual keys, eliminating the need for RMW operations.
///
/// ### SK2 Observer Mode Transaction IDs
/// `registerNewSyncedSK2ObserverModeTransactionIDs()` performs RMW using a dedicated lock
/// (`cachedSyncedSK2ObserverModeTransactionIDsLock`) that is separate from the subscriber attributes lock.
///
/// - SeeAlso: https://github.com/RevenueCat/purchases-ios/issues/4137
/// - SeeAlso: https://github.com/RevenueCat/purchases-ios/issues/5729
///
class DeviceCache {
private static let defaultBasePath = "device-cache"
private static let oldDefaultBasePath = "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 lockingUserDefaults: LockingSynchronizedUserDefaults
private let largeItemCache: SynchronizedLargeItemCache
private let offeringsCachedObject: InMemoryCachedObject<Offerings>
private let fileManager: FileManager
private let _cachedAppUserID: Atomic<String?>
private let _cachedLegacyAppUserID: Atomic<String?>
private let offeringsCachePreferredLocales: Atomic<[String]> = .init([])
private let migrationLock = Lock(.nonRecursive)
init(systemInfo: SystemInfo,
userDefaults: UserDefaults,
cache: LargeItemCacheType = FileManager.default,
fileManager: FileManager = FileManager.default,
offeringsCachedObject: InMemoryCachedObject<Offerings> = .init()) {
self.offeringsCachedObject = offeringsCachedObject
self.systemInfo = systemInfo
self.userDefaults = .init(userDefaults: userDefaults)
self.lockingUserDefaults = .init(userDefaults: userDefaults)
self._cachedAppUserID = .init(userDefaults.string(forKey: CacheKeys.appUserDefaults))
self._cachedLegacyAppUserID = .init(userDefaults.string(forKey: CacheKeys.legacyGeneratedAppUserDefaults))
self.largeItemCache = .init(
cache: cache,
basePath: Self.defaultBasePath
)
self.fileManager = fileManager
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)
}
// Try to get from new cache location first
if let value: Value = try? self.largeItemCache.value(forKey: key.rawValue) {
return value
}
// Check old documents directory and migrate if found
return self.migrateAndReturnValueIfNeeded(for: key.rawValue)
}
// 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.lockingUserDefaults.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).rawValue)
// Delete old offerings file from documents directory if it exists
self.deleteOldFileIfNeeded(for: CacheKey.offerings(oldAppUserID).rawValue)
}
// 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.value = preferredLocales
let key = CacheKey.offerings(appUserID).rawValue
if self.largeItemCache.set(codable: offerings.contents, forKey: key) {
// Delete old file from documents directory if it exists
self.deleteOldFileIfNeeded(for: key)
}
}
func cacheInMemory(offerings: Offerings) {
self.offeringsCachedObject.cache(instance: offerings)
}
func clearInMemoryOfferingsCache() {
self.offeringsCachedObject.clearCache()
}
func clearOfferingsCache(appUserID: String) {
self.offeringsCachedObject.clearCache()
self.offeringsCachePreferredLocales.value = []
self.largeItemCache.removeObject(forKey: CacheKey.offerings(appUserID).rawValue)
// Delete old offerings file from documents directory if it exists
self.deleteOldFileIfNeeded(for: CacheKey.offerings(appUserID).rawValue)
}
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.value != self.systemInfo.preferredLocales
}
func forceOfferingsCacheStale() {
self.offeringsCachedObject.clearCacheTimestamp()
self.offeringsCachePreferredLocales.value = []
}
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
// Write operations use `lockingUserDefaults` to ensure atomic read-modify-write.
// Read operations use the lock-free `userDefaults` since they don't modify data.
// This will be addressed in a future PR by refactoring subscriber attributes to use
// individual keys, eliminating the need for RMW operations.
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.lockingUserDefaults.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.lockingUserDefaults.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.lockingUserDefaults.write {
guard Self.unsyncedAttributesByKey($0, appUserID: appUserID).isEmpty else {
return
}
Self.deleteAllAttributes($0, appUserID: appUserID)
}
}
func copySubscriberAttributes(oldAppUserID: String, newAppUserID: String) {
self.lockingUserDefaults.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) {
let productEntitlementMappingKey = CacheKeys.productEntitlementMapping.rawValue
if self.largeItemCache.set(
codable: productEntitlementMapping,
forKey: productEntitlementMappingKey
) {
self.userDefaults.write {
$0.set(Date(), forKey: CacheKeys.productEntitlementMappingLastUpdated)
}
// Delete old file if it still exists
self.deleteOldFileIfNeeded(for: productEntitlementMappingKey)
}
}
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"
}
internal 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)
/*
We were previously storing these cache files in the Documents directory
which may end up in the Files app or the user's Documents directory on macOS.
We'll migrate files to the caches directory and try to delete the old documents
directory if it's empty after migrating.
*/
// MARK: - Migration Helpers
// swiftlint:disable avoid_using_directory_apis_directly
private func oldDocumentsDirectoryURL() -> URL? {
let documentsDirectoryURL: URL?
if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) {
documentsDirectoryURL = URL.documentsDirectory
} else {
documentsDirectoryURL = fileManager.urls(
for: .documentDirectory,
in: .userDomainMask
).first
}
return documentsDirectoryURL?.appendingPathComponent(Self.oldDefaultBasePath)
}
// swiftlint:enable avoid_using_directory_apis_directly
private func migrateAndReturnValueIfNeeded<Value: Codable>(for key: String) -> Value? {
return self.migrationLock.perform {
guard let oldDirectoryURL = self.oldDocumentsDirectoryURL() else { return nil }
let oldFileURL = oldDirectoryURL.appendingPathComponent(key)
// Check if file already exists in new location (it may have been migrated before the lock was released)
if let value: Value = try? self.largeItemCache.value(forKey: key) {
// File already migrated, clean up old file if it still exists
if fileManager.fileExists(atPath: oldFileURL.path) {
try? fileManager.removeItem(at: oldFileURL)
self.deleteOldDocumentsDirectoryIfEmpty()
}
return value
}
// Make sure the old file (still) exists
guard fileManager.fileExists(atPath: oldFileURL.path) else {
return nil
}
// Try to load from old location
// If decoding of the file from the old location fails, remove it since the file is corrupt
guard let data = try? Data(contentsOf: oldFileURL),
let value: Value = try? JSONDecoder.default.decode(jsonData: data, logErrors: true) else {
try? fileManager.removeItem(at: oldFileURL)
return nil
}
guard let newCacheURL = fileManager.createCacheDirectoryIfNeeded(basePath: Self.defaultBasePath) else {
return nil
}
let newFileURL = newCacheURL.appendingPathComponent(key)
// Make sure the new location exists
guard fileManager.fileExists(atPath: newCacheURL.path) else {
return value
}
// Migrate file to new location
do {
try fileManager.moveItem(at: oldFileURL, to: newFileURL)
self.deleteOldDocumentsDirectoryIfEmpty()
} catch {
Logger.error(Strings.cache.failed_to_migrate_file(oldFileURL.path, error))
}
return value
}
}
private func deleteOldFileIfNeeded(for key: String) {
guard let oldDirectoryURL = self.oldDocumentsDirectoryURL() else {
return
}
let oldFileURL = oldDirectoryURL.appendingPathComponent(key)
// Use fileManager directly for file operations since LargeItemCacheType doesn't provide fileExists
guard fileManager.fileExists(atPath: oldFileURL.path) else {
return
}
// Delete old file if it exists
do {
try fileManager.removeItem(at: oldFileURL)
self.deleteOldDocumentsDirectoryIfEmpty()
} catch {
Logger.error(Strings.cache.failed_to_delete_old_cache_directory(error))
}
}
private func deleteOldDocumentsDirectoryIfEmpty() {
guard let oldDirectoryURL = self.oldDocumentsDirectoryURL() else {
return
}
// Use fileManager directly for file operations since LargeItemCacheType doesn't provide these
guard fileManager.fileExists(atPath: oldDirectoryURL.path),
(try? fileManager.contentsOfDirectory(atPath: oldDirectoryURL.path).isEmpty) == true else {
return
}
do {
try fileManager.removeItem(at: oldDirectoryURL)
} catch {
Logger.error(Strings.cache.failed_to_delete_old_cache_directory(error))
}
}
}
protocol DeviceCacheKeyType {
var rawValue: String { get }
}