Skip to content

Commit b86cec1

Browse files
committed
[#639] Add local and remote config management with AppConfig
1 parent 4cf2e99 commit b86cec1

9 files changed

Lines changed: 452 additions & 0 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
//
2+
// AnyCodingKey.swift
3+
//
4+
5+
import Foundation
6+
7+
/// A flexible `CodingKey` backed by a plain string or integer.
8+
/// Use this for brand-specific remote config keys instead of a fixed enum.
9+
public struct AnyCodingKey: CodingKey, Hashable {
10+
11+
public let stringValue: String
12+
public let intValue: Int?
13+
14+
public init(stringValue: String) {
15+
self.stringValue = stringValue
16+
intValue = nil
17+
}
18+
19+
public init(intValue: Int) {
20+
stringValue = "\(intValue)"
21+
self.intValue = intValue
22+
}
23+
24+
public init<Key>(_ base: Key) where Key: CodingKey {
25+
if let intValue = base.intValue {
26+
self.init(intValue: intValue)
27+
} else {
28+
self.init(stringValue: base.stringValue)
29+
}
30+
}
31+
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
//
2+
// AppConfig.swift
3+
//
4+
5+
import Combine
6+
import FirebaseRemoteConfig
7+
import Foundation
8+
9+
// MARK: - AppConfigProtocol
10+
11+
public protocol AppConfigProtocol<DecodedConfig> {
12+
13+
associatedtype DecodedConfig
14+
15+
var currentConfigPublisher: AnyPublisher<DecodedConfig, Never> { get }
16+
var currentConfig: DecodedConfig { get }
17+
18+
func setUp()
19+
func getAllKeysFromDefault() -> [String]
20+
func getAllKeysFromRemote() -> [String]
21+
}
22+
23+
// MARK: - AppConfig
24+
25+
/// A generic Firebase Remote Config manager.
26+
public final class AppConfig<DecodedConfig: Sendable>: AppConfigProtocol {
27+
28+
private var remoteConfig: RemoteConfig?
29+
private let defaultConfig: AppDefaultConfig
30+
private let configMapper: (RemoteConfigDecoder) -> DecodedConfig
31+
32+
public let currentConfigSubject: CurrentValueSubject<DecodedConfig, Never>
33+
34+
public var currentConfigPublisher: AnyPublisher<DecodedConfig, Never> {
35+
currentConfigSubject.eraseToAnyPublisher()
36+
}
37+
38+
public var currentConfig: DecodedConfig {
39+
currentConfigSubject.value
40+
}
41+
42+
public init(
43+
defaultConfig: AppDefaultConfig = AppDefaultConfig(),
44+
initialConfig: DecodedConfig,
45+
configMapper: @escaping (RemoteConfigDecoder) -> DecodedConfig
46+
) {
47+
self.defaultConfig = defaultConfig
48+
self.configMapper = configMapper
49+
currentConfigSubject = CurrentValueSubject(initialConfig)
50+
}
51+
52+
public func setUp() {
53+
remoteConfig = RemoteConfig.remoteConfig()
54+
setUpConfigSettings()
55+
setUpDefaults()
56+
setUpListener()
57+
fetchAndActivate()
58+
}
59+
60+
public func getAllKeysFromDefault() -> [String] {
61+
remoteConfig?.allKeys(from: .default) ?? []
62+
}
63+
64+
public func getAllKeysFromRemote() -> [String] {
65+
remoteConfig?.allKeys(from: .remote) ?? []
66+
}
67+
}
68+
69+
// MARK: - Firebase wiring
70+
71+
extension AppConfig {
72+
73+
private func setUpConfigSettings() {
74+
let settings = RemoteConfigSettings()
75+
#if DEBUG || DEV
76+
settings.minimumFetchInterval = .zero
77+
#endif
78+
remoteConfig?.configSettings = settings
79+
}
80+
81+
private func setUpDefaults() {
82+
do {
83+
try remoteConfig?.setDefaults(from: defaultConfig)
84+
} catch {
85+
#if DEBUG || DEV
86+
NSLog("[AppConfig] Failed to set defaults: \(error).")
87+
#endif
88+
}
89+
}
90+
91+
private func setUpListener() {
92+
remoteConfig?.addOnConfigUpdateListener { [weak self] _, error in
93+
guard let self else { return }
94+
if logError(error, context: "Listener update") { return }
95+
remoteConfig?.activate { [weak self] _, error in
96+
guard let self else { return }
97+
if logError(error, context: "Listener activate") { return }
98+
publishUpdatedConfig()
99+
}
100+
}
101+
}
102+
103+
private func fetchAndActivate() {
104+
remoteConfig?.fetchAndActivate { [weak self] _, error in
105+
guard let self else { return }
106+
if logError(error, context: "Fetch and activate") { return }
107+
publishUpdatedConfig()
108+
}
109+
}
110+
111+
private func publishUpdatedConfig() {
112+
guard let remoteConfig else { return }
113+
let decoder = RemoteConfigDecoder(remoteConfig: remoteConfig)
114+
currentConfigSubject.send(configMapper(decoder))
115+
}
116+
117+
@discardableResult
118+
private func logError(_ error: (any Error)?, context: String) -> Bool {
119+
guard let error else { return false }
120+
#if DEBUG || DEV
121+
NSLog("[AppConfig] \(context) failed: \(error).")
122+
#endif
123+
return true
124+
}
125+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
//
2+
// AppDefaultConfig.swift
3+
//
4+
5+
import Foundation
6+
7+
/// Wraps an arbitrary key-value dictionary to be loaded as Firebase Remote Config defaults.
8+
public struct AppDefaultConfig: Encodable {
9+
10+
public var configs: [AnyCodingKey: any Encodable]
11+
12+
public init(configs: [AnyCodingKey: any Encodable] = [:]) {
13+
self.configs = configs
14+
}
15+
16+
public static func build(
17+
configs: [String: any Encodable] = [:],
18+
additionalConfigs: [AnyCodingKey: any Encodable] = [:]
19+
) -> AppDefaultConfig {
20+
var keyed: [AnyCodingKey: any Encodable] = additionalConfigs
21+
for (key, value) in configs {
22+
keyed[AnyCodingKey(stringValue: key)] = value
23+
}
24+
return AppDefaultConfig(configs: keyed)
25+
}
26+
27+
public func encode(to encoder: any Encoder) throws {
28+
var container = encoder.container(keyedBy: AnyCodingKey.self)
29+
for (key, value) in configs {
30+
try container.encode(AnyEncodable(value), forKey: key)
31+
}
32+
}
33+
}
34+
35+
// MARK: - AnyEncodable helper
36+
37+
private struct AnyEncodable: Encodable {
38+
39+
let value: any Encodable
40+
41+
init(_ value: any Encodable) {
42+
self.value = value
43+
}
44+
45+
func encode(to encoder: any Encoder) throws {
46+
try value.encode(to: encoder)
47+
}
48+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
//
2+
// ExampleAppConfiguration.swift
3+
//
4+
5+
import Foundation
6+
7+
/// Example configuration structure that demonstrates how to use AppConfig.
8+
/// Replace this with your app-specific configuration in real projects.
9+
public struct ExampleAppConfiguration: Sendable {
10+
11+
public let isFeatureEnabled: Bool
12+
public let maxRetryCount: Int
13+
public let apiTimeout: Double
14+
public let welcomeMessage: String
15+
16+
public init(
17+
isFeatureEnabled: Bool = false,
18+
maxRetryCount: Int = 3,
19+
apiTimeout: Double = 30.0,
20+
welcomeMessage: String = "Welcome"
21+
) {
22+
self.isFeatureEnabled = isFeatureEnabled
23+
self.maxRetryCount = maxRetryCount
24+
self.apiTimeout = apiTimeout
25+
self.welcomeMessage = welcomeMessage
26+
}
27+
}
28+
29+
public enum ExampleConfigKey: String, CodingKey {
30+
31+
case isFeatureEnabled = "feature_enabled"
32+
case maxRetryCount = "max_retry_count"
33+
case apiTimeout = "api_timeout"
34+
case welcomeMessage = "welcome_message"
35+
}
36+
37+
public func createExampleAppConfig() -> AppConfig<ExampleAppConfiguration> {
38+
let defaultConfig = AppDefaultConfig.build(configs: [
39+
ExampleConfigKey.isFeatureEnabled.rawValue: false,
40+
ExampleConfigKey.maxRetryCount.rawValue: 3,
41+
ExampleConfigKey.apiTimeout.rawValue: 30.0,
42+
ExampleConfigKey.welcomeMessage.rawValue: "Welcome to {PROJECT_NAME}"
43+
])
44+
45+
let initialConfig = ExampleAppConfiguration()
46+
47+
let configMapper: (RemoteConfigDecoder) -> ExampleAppConfiguration = { decoder in
48+
ExampleAppConfiguration(
49+
isFeatureEnabled: decoder.decodeBool(forKey: ExampleConfigKey.isFeatureEnabled.rawValue),
50+
maxRetryCount: decoder.decodeNumber(forKey: ExampleConfigKey.maxRetryCount.rawValue)?.intValue ?? 3,
51+
apiTimeout: decoder.decodeNumber(forKey: ExampleConfigKey.apiTimeout.rawValue)?.doubleValue ?? 30.0,
52+
welcomeMessage: decoder.decodeString(forKey: ExampleConfigKey.welcomeMessage.rawValue) ?? "Welcome"
53+
)
54+
}
55+
56+
return AppConfig(
57+
defaultConfig: defaultConfig,
58+
initialConfig: initialConfig,
59+
configMapper: configMapper
60+
)
61+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
//
2+
// FirebaseRemoteConfigSource.swift
3+
//
4+
5+
import Domain
6+
import FirebaseRemoteConfig
7+
import Foundation
8+
9+
// MARK: - RemoteConfigInterface
10+
11+
/// Abstracts the Firebase `RemoteConfig` API surface used by `FirebaseRemoteConfigSource`,
12+
/// enabling the class to be tested without subclassing the Firebase singleton.
13+
protocol RemoteConfigInterface {
14+
func fetchAndActivate(completionHandler: ((RemoteConfigFetchAndActivateStatus, (any Error)?) -> Void)?)
15+
func configEntry(forKey key: String) -> (data: Data, source: FirebaseRemoteConfig.RemoteConfigSource)
16+
}
17+
18+
extension RemoteConfig: RemoteConfigInterface {
19+
func configEntry(forKey key: String) -> (data: Data, source: FirebaseRemoteConfig.RemoteConfigSource) {
20+
let value = self[key]
21+
return (value.dataValue, value.source)
22+
}
23+
}
24+
25+
// MARK: - FirebaseRemoteConfigSource
26+
27+
/// Firebase Remote Config implementation of `RemoteConfigSource` protocol.
28+
/// Bridges Firebase Remote Config to the existing Domain layer interface.
29+
public final class FirebaseRemoteConfigSource: RemoteConfigSource {
30+
31+
private let remoteConfig: any RemoteConfigInterface
32+
33+
public convenience init(remoteConfig: RemoteConfig = RemoteConfig.remoteConfig()) {
34+
self.init(config: remoteConfig)
35+
}
36+
37+
init(config: any RemoteConfigInterface) {
38+
self.remoteConfig = config
39+
}
40+
41+
public func refresh() async throws {
42+
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
43+
remoteConfig.fetchAndActivate { _, error in
44+
if let error {
45+
continuation.resume(throwing: error)
46+
} else {
47+
continuation.resume()
48+
}
49+
}
50+
}
51+
}
52+
53+
public func value(forKey key: String) async -> RemoteConfigStoredValue? {
54+
let (data, source) = remoteConfig.configEntry(forKey: key)
55+
56+
guard source != .static || !data.isEmpty else {
57+
return nil
58+
}
59+
60+
guard !data.isEmpty else {
61+
return nil
62+
}
63+
64+
if let string = String(data: data, encoding: .utf8) {
65+
if let boolValue = string.normalizedRemoteConfigBoolean {
66+
return .bool(boolValue)
67+
}
68+
69+
if let intValue = Int(string.trimmingCharacters(in: .whitespacesAndNewlines)) {
70+
return .int(intValue)
71+
}
72+
73+
if let doubleValue = Double(string.trimmingCharacters(in: .whitespacesAndNewlines)) {
74+
return .double(doubleValue)
75+
}
76+
77+
return .string(string)
78+
}
79+
80+
return .data(data)
81+
}
82+
}
83+
84+
// MARK: - String extension for boolean parsing
85+
86+
private extension String {
87+
88+
var normalizedRemoteConfigBoolean: Bool? {
89+
switch trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
90+
case "1", "true", "yes", "y", "on":
91+
return true
92+
case "0", "false", "no", "n", "off":
93+
return false
94+
default:
95+
return nil
96+
}
97+
}
98+
}

0 commit comments

Comments
 (0)