Files
purchases-ios/Sources/Networking/AudiencesConfigProvider.swift
Rick 7bbbe5e622 refactor(remote-config): introduce config lifecycle observers (#7875)
* refactor(remote-config): introduce config lifecycle observers

* refactor(remote-config): simplify SDK settings delivery

* test(remote-config): cover lifecycle terminal failure
2026-10-01 13:50:58 +02:00

146 lines
5.0 KiB
Swift

//
// AudiencesConfigProvider.swift
// RevenueCat
//
// Created by Cesar de la Vega.
// Copyright © 2026 RevenueCat, Inc. All rights reserved.
import Foundation
protocol AudiencesConfigProviderType {
func configuration() async throws -> AudienceConfigurationSnapshot?
func isCurrent(_ snapshot: AudienceConfigurationSnapshot) -> Bool
}
/// One immutable view of the audience rules served by the `audiences` topic.
struct AudienceConfigurationSnapshot: Equatable, Sendable {
let audiences: [String: Audience]
let configGeneration: Int
}
/// The topic-specific front door for canonical audience configuration.
///
/// All published audience rules live in the immutable `default` blob.
final class AudiencesConfigProvider: AudiencesConfigProviderType, RemoteConfigLifecycleObserver {
private let manager: RemoteConfigManagerType
private let cachedConfiguration = GenerationGuardedCache<
RemoteConfiguration.ConfigTopic,
AudienceConfigurationSnapshot
>()
init(manager: RemoteConfigManagerType) {
self.manager = manager
}
func remoteConfigEventReceived(_ event: RemoteConfigLifecycleEvent) {
switch event {
case .observerRegistered, .committed:
Task { [weak self] in
await self?.warm()
}
case .refreshFinished:
break
}
}
func warm() async {
guard let snapshot = await self.manager.committedTopicCacheSnapshot(.audiences),
snapshot.key[Self.audiencesBlobItemKey]?.prefetch == true,
let blob = await self.manager.blobData(
for: .audiences,
itemKey: Self.audiencesBlobItemKey,
policy: .cachedOnly
) else { return }
guard let audiences = try? Self.decodeAudiences(from: blob),
self.manager.configGeneration == snapshot.generation else { return }
self.cachedConfiguration.store(
.init(audiences: audiences, configGeneration: snapshot.generation),
for: snapshot
)
}
func configuration() async throws -> AudienceConfigurationSnapshot? {
if let cached = self.cachedConfiguration.value(currentGeneration: self.manager.configGeneration) {
return cached
}
return try await self.manager.readConsistent {
try await self.loadConfiguration()
}
}
private func loadConfiguration() async throws -> AudienceConfigurationSnapshot? {
try Task.checkCancellation()
guard let topic = await self.manager.awaitTopicAndPrefetchBlobsReady(.audiences) else { return nil }
let topicSnapshot = GenerationGuardedCacheSnapshot(
generation: self.manager.configGeneration,
key: topic
)
guard await self.manager.isCurrent(topicSnapshot, for: .audiences) else { return nil }
if let cached = self.cachedConfiguration.value(for: topicSnapshot) { return cached }
do {
guard topicSnapshot.key[Self.audiencesBlobItemKey] != nil,
let blob = await self.manager.blobData(
for: .audiences,
itemKey: Self.audiencesBlobItemKey
) else {
return nil
}
let configuration = AudienceConfigurationSnapshot(
audiences: try Self.decodeAudiences(from: blob),
configGeneration: topicSnapshot.generation
)
guard await self.manager.isCurrent(topicSnapshot, for: .audiences) else { return nil }
self.cachedConfiguration.store(configuration, for: topicSnapshot)
return configuration
} catch {
guard await self.manager.isCurrent(topicSnapshot, for: .audiences) else { return nil }
Logger.error(Strings.remoteConfig.audienceConfigurationDecodeFailed(error))
throw error
}
}
func isCurrent(_ snapshot: AudienceConfigurationSnapshot) -> Bool {
return self.manager.configGeneration == snapshot.configGeneration
}
private static func decodeAudiences(from data: Data) throws -> [String: Audience] {
let entries = try JSONDecoder.default.decode([String: FailableAudience].self, from: data)
return entries.reduce(into: [:]) { audiences, entry in
let (mapKey, decoded) = entry
switch decoded.result {
case .success(let audience):
audiences[mapKey] = audience
case .failure(let error):
Logger.error(Strings.remoteConfig.audienceDecodeFailed(identifier: mapKey, error: error))
}
}
}
private static let audiencesBlobItemKey = "default"
}
extension AudiencesConfigProvider: @unchecked Sendable {}
private struct FailableAudience: Decodable {
let result: Result<Audience, Error>
init(from decoder: Decoder) throws {
do {
self.result = .success(try Audience(from: decoder))
} catch {
self.result = .failure(error)
}
}
}