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