mirror of
https://github.com/RevenueCat/purchases-ios
synced 2026-11-11 22:55:55 +05:45
* 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
976 lines
38 KiB
Swift
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 }
|
|
|
|
}
|