Skip to content

Commit bcdbef9

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

9 files changed

Lines changed: 475 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: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
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+
///
27+
/// Supply an `initialConfig` value and a `configMapper` closure that translates a live
28+
/// `RemoteConfigDecoder` into your project-specific decoded config type. The class handles
29+
/// all Firebase wiring—settings, defaults, real-time listener, and fetch-and-activate—and
30+
/// re-publishes updated values through `currentConfigSubject` whenever the remote config
31+
/// changes.
32+
///
33+
public final class AppConfig<DecodedConfig: Sendable>: AppConfigProtocol {
34+
35+
private var remoteConfig: RemoteConfig?
36+
private let defaultConfig: AppDefaultConfig
37+
private let configMapper: (RemoteConfigDecoder) -> DecodedConfig
38+
39+
public let currentConfigSubject: CurrentValueSubject<DecodedConfig, Never>
40+
41+
public var currentConfigPublisher: AnyPublisher<DecodedConfig, Never> {
42+
currentConfigSubject.eraseToAnyPublisher()
43+
}
44+
45+
public var currentConfig: DecodedConfig {
46+
currentConfigSubject.value
47+
}
48+
49+
public init(
50+
defaultConfig: AppDefaultConfig = AppDefaultConfig(),
51+
initialConfig: DecodedConfig,
52+
configMapper: @escaping (RemoteConfigDecoder) -> DecodedConfig
53+
) {
54+
self.defaultConfig = defaultConfig
55+
self.configMapper = configMapper
56+
currentConfigSubject = CurrentValueSubject(initialConfig)
57+
}
58+
59+
public func setUp() {
60+
remoteConfig = RemoteConfig.remoteConfig()
61+
setUpConfigSettings()
62+
setUpDefaults()
63+
setUpListener()
64+
fetchAndActivate()
65+
}
66+
67+
public func getAllKeysFromDefault() -> [String] {
68+
remoteConfig?.allKeys(from: .default) ?? []
69+
}
70+
71+
public func getAllKeysFromRemote() -> [String] {
72+
remoteConfig?.allKeys(from: .remote) ?? []
73+
}
74+
}
75+
76+
// MARK: - Firebase wiring
77+
78+
extension AppConfig {
79+
80+
private func setUpConfigSettings() {
81+
let settings = RemoteConfigSettings()
82+
#if DEBUG || DEV
83+
settings.minimumFetchInterval = .zero
84+
#endif
85+
remoteConfig?.configSettings = settings
86+
}
87+
88+
private func setUpDefaults() {
89+
do {
90+
try remoteConfig?.setDefaults(from: defaultConfig)
91+
} catch {
92+
#if DEBUG || DEV
93+
NSLog("[AppConfig] Failed to set defaults: \(error).")
94+
#endif
95+
}
96+
}
97+
98+
private func setUpListener() {
99+
remoteConfig?.addOnConfigUpdateListener { [weak self] _, error in
100+
guard let self else { return }
101+
if logError(error, context: "Listener update") { return }
102+
remoteConfig?.activate { [weak self] _, error in
103+
guard let self else { return }
104+
if logError(error, context: "Listener activate") { return }
105+
publishUpdatedConfig()
106+
}
107+
}
108+
}
109+
110+
private func fetchAndActivate() {
111+
remoteConfig?.fetchAndActivate { [weak self] _, error in
112+
guard let self else { return }
113+
if logError(error, context: "Fetch and activate") { return }
114+
publishUpdatedConfig()
115+
}
116+
}
117+
118+
private func publishUpdatedConfig() {
119+
guard let remoteConfig else { return }
120+
let decoder = RemoteConfigDecoder(remoteConfig: remoteConfig)
121+
currentConfigSubject.send(configMapper(decoder))
122+
}
123+
124+
@discardableResult
125+
private func logError(_ error: (any Error)?, context: String) -> Bool {
126+
guard let error else { return false }
127+
#if DEBUG || DEV
128+
NSLog("[AppConfig] \(context) failed: \(error).")
129+
#endif
130+
return true
131+
}
132+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
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+
///
9+
/// Build one with `AppDefaultConfig.build(configs:)` passing plain `String` keys, or
10+
/// construct it directly with `AnyCodingKey` keys when you need type-safe coding keys.
11+
public struct AppDefaultConfig: Encodable {
12+
13+
public var configs: [AnyCodingKey: any Encodable]
14+
15+
public init(configs: [AnyCodingKey: any Encodable] = [:]) {
16+
self.configs = configs
17+
}
18+
19+
/// Convenience factory that converts a `[String: any Encodable]` dictionary into an
20+
/// `AppDefaultConfig`, optionally merging with additional typed-key entries.
21+
public static func build(
22+
configs: [String: any Encodable] = [:],
23+
additionalConfigs: [AnyCodingKey: any Encodable] = [:]
24+
) -> AppDefaultConfig {
25+
var keyed: [AnyCodingKey: any Encodable] = additionalConfigs
26+
for (key, value) in configs {
27+
keyed[AnyCodingKey(stringValue: key)] = value
28+
}
29+
return AppDefaultConfig(configs: keyed)
30+
}
31+
32+
public func encode(to encoder: any Encoder) throws {
33+
var container = encoder.container(keyedBy: AnyCodingKey.self)
34+
for (key, value) in configs {
35+
try container.encode(AnyEncodable(value), forKey: key)
36+
}
37+
}
38+
}
39+
40+
// MARK: - AnyEncodable helper
41+
42+
private struct AnyEncodable: Encodable {
43+
44+
let value: any Encodable
45+
46+
init(_ value: any Encodable) {
47+
self.value = value
48+
}
49+
50+
func encode(to encoder: any Encoder) throws {
51+
try value.encode(to: encoder)
52+
}
53+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
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+
/// Example coding keys for type-safe configuration access
30+
public enum ExampleConfigKey: String, CodingKey {
31+
case isFeatureEnabled = "feature_enabled"
32+
case maxRetryCount = "max_retry_count"
33+
case apiTimeout = "api_timeout"
34+
case welcomeMessage = "welcome_message"
35+
}
36+
37+
/// Example factory function that creates an AppConfig instance
38+
public func createExampleAppConfig() -> AppConfig<ExampleAppConfiguration> {
39+
// Set up default values
40+
let defaultConfig = AppDefaultConfig.build(configs: [
41+
ExampleConfigKey.isFeatureEnabled.rawValue: false,
42+
ExampleConfigKey.maxRetryCount.rawValue: 3,
43+
ExampleConfigKey.apiTimeout.rawValue: 30.0,
44+
ExampleConfigKey.welcomeMessage.rawValue: "Welcome to {PROJECT_NAME}"
45+
])
46+
47+
// Initial configuration (fallback values)
48+
let initialConfig = ExampleAppConfiguration()
49+
50+
// Configuration mapper that decodes remote values
51+
let configMapper: (RemoteConfigDecoder) -> ExampleAppConfiguration = { decoder in
52+
ExampleAppConfiguration(
53+
isFeatureEnabled: decoder.decodeBool(forKey: ExampleConfigKey.isFeatureEnabled.rawValue),
54+
maxRetryCount: decoder.decodeNumber(forKey: ExampleConfigKey.maxRetryCount.rawValue)?.intValue ?? 3,
55+
apiTimeout: decoder.decodeNumber(forKey: ExampleConfigKey.apiTimeout.rawValue)?.doubleValue ?? 30.0,
56+
welcomeMessage: decoder.decodeString(forKey: ExampleConfigKey.welcomeMessage.rawValue) ?? "Welcome"
57+
)
58+
}
59+
60+
return AppConfig(
61+
defaultConfig: defaultConfig,
62+
initialConfig: initialConfig,
63+
configMapper: configMapper
64+
)
65+
}
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)