Files
purchases-ios/Sources/Networking/RemoteConfigDiskCache.swift
Rick f0eaeff7fe feat(remote-config): use the server clock for X-RC-Last-Refresh-Time (#7314)
* Use server time for remote config refreshes

* Assert production remote config request dates

* Clarify remote config refresh timestamps

* Test production refresh time round trip
2026-07-30 15:10:49 +02:00

196 lines
5.8 KiB
Swift

//
// RemoteConfigDiskCache.swift
// RevenueCat
//
// Created by Rick van der Linden.
// Copyright © 2026 RevenueCat, Inc. All rights reserved.
import Foundation
/// Read-only access to a topic's persisted item index (metadata only, no blob bytes or waiting).
protocol RemoteConfigTopicStoreType: AnyObject {
/// The saved items for `topic`, or `nil` when nothing has been persisted yet.
func topic(_ topic: RemoteConfigTopic) -> RemoteConfiguration.ConfigTopic?
}
protocol RemoteConfigDiskCacheType: RemoteConfigTopicStoreType {
func read() -> PersistedRemoteConfiguration?
@discardableResult
func write(_ configuration: PersistedRemoteConfiguration) -> Bool
func clear()
}
struct PersistedRemoteConfiguration: Codable, Equatable {
let domain: String
let manifest: String
let activeTopics: [String]
let prefetchBlobs: [String]
let topics: RemoteConfiguration.Topics
/// The most recent `X-RevenueCat-Request-Time` supplied by the main API, stored as epoch milliseconds.
///
/// This value is replayed as `X-RC-Last-Refresh-Time` on the next config request.
private(set) var lastRefreshTimeMilliseconds: UInt64?
init(
domain: String = RemoteConfiguration.defaultDomain,
manifest: String,
activeTopics: [String] = [],
prefetchBlobs: [String] = [],
topics: RemoteConfiguration.Topics = .init(),
lastRefreshTimeMilliseconds: UInt64? = nil
) {
self.domain = domain
self.manifest = manifest
self.activeTopics = activeTopics
self.prefetchBlobs = prefetchBlobs
self.topics = topics
self.lastRefreshTimeMilliseconds = lastRefreshTimeMilliseconds
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.init(
domain: try container.decodeIfPresent(String.self, forKey: .domain) ?? RemoteConfiguration.defaultDomain,
manifest: try container.decode(String.self, forKey: .manifest),
activeTopics: try container.decodeIfPresent([String].self, forKey: .activeTopics) ?? [],
prefetchBlobs: try container.decodeIfPresent([String].self, forKey: .prefetchBlobs) ?? [],
topics: try container.decodeIfPresent(RemoteConfiguration.Topics.self, forKey: .topics) ?? .init(),
lastRefreshTimeMilliseconds: try container.decodeIfPresent(
UInt64.self,
forKey: .lastRefreshTimeMilliseconds
)
)
}
private enum CodingKeys: String, CodingKey {
case domain
case manifest
case activeTopics
case prefetchBlobs
case topics
case lastRefreshTimeMilliseconds
}
}
extension PersistedRemoteConfiguration {
/// The persisted main-server request time in the `Date` representation used by the request layer.
var lastRefreshTime: Date? {
return self.lastRefreshTimeMilliseconds.map(Date.init(millisecondsSince1970:))
}
/// Returns a copy whose replayed refresh time is updated from a successful main-server response.
func withLastRefreshTime(_ date: Date) -> Self {
var copy = self
copy.lastRefreshTimeMilliseconds = date.millisecondsSince1970
return copy
}
}
final class RemoteConfigDiskCache: RemoteConfigDiskCacheType {
private let cache: SynchronizedLargeItemCache
private let snapshot: Atomic<Snapshot> = .init(.notLoaded)
init(
cache: SynchronizedLargeItemCache = .init(
cache: FileManager.default,
basePath: RemoteConfigDiskCache.basePath,
directoryType: RemoteConfigDiskCache.directoryType
)
) {
self.cache = cache
}
func read() -> PersistedRemoteConfiguration? {
return self.snapshot.modify { snapshot in
switch snapshot {
case .notLoaded:
let configuration = self.readFromDisk()
snapshot = .loaded(configuration)
return configuration
case .loaded(let configuration):
return configuration
}
}
}
@discardableResult
func write(_ configuration: PersistedRemoteConfiguration) -> Bool {
return self.snapshot.modify { snapshot in
let didWrite = self.cache.set(codable: configuration, forKey: Self.fileName)
if didWrite {
snapshot = .loaded(configuration)
} else {
Logger.error(Strings.remoteConfig.failedToWriteCache)
}
return didWrite
}
}
func clear() {
self.snapshot.modify { snapshot in
self.cache.clear()
snapshot = .loaded(nil)
}
}
private func readFromDisk() -> PersistedRemoteConfiguration? {
do {
return try self.cache.value(forKey: Self.fileName, decoder: .default)
} catch {
Logger.error(Strings.remoteConfig.failedToReadCache(error))
return nil
}
}
}
extension RemoteConfigDiskCache {
/// In-memory snapshot of the persisted configuration, so `read()` only hits disk once.
/// `.loaded(nil)` means we've already checked and nothing is persisted, avoiding repeated
/// disk reads while no configuration exists.
private enum Snapshot {
case notLoaded
case loaded(PersistedRemoteConfiguration?)
}
}
extension RemoteConfigDiskCache: RemoteConfigTopicStoreType {
func topic(_ topic: RemoteConfigTopic) -> RemoteConfiguration.ConfigTopic? {
return self.read()?.topics.entries[topic.wireName]
}
}
extension RemoteConfigDiskCache {
static let basePath = "remote_config"
static let fileName = "remote_config.json"
static var directoryType: DirectoryHelper.DirectoryType {
#if os(tvOS)
return .cache
#else
return .applicationSupport()
#endif
}
}