firebase-ios-sdk
firebase-ios-sdk copied to clipboard
FirebaseRemoteConfig issue with Codable default config
Description
Hi, I just discovered a strange issue when setting a default config with a Codable containing a Date attribute to Firebase Remote Config on iOS.
What I thing its happening is: since Remote Config uses its own JSONEncoder internally it takes the Date and parses it using a unix format (ex: 666140400) while in Firebase Remote Config dashboard the dates in the JSON are in iso8601 format. So when my app tries to fetch the config from Firebase, if for some reason it fails or doesn't manage to retrieve the one online and has to resort to the default one, my code will fail to parse the returned data because im trying to decode an iso8601 date in my Codable, and the returned one is unix only in this particular case, even though the configs I set both locally and online have iso8601 dates.
I think a possible way of solving this would be if we could configure manually the encoding/decoding strategy that Firebase uses internally, to guarantee that the data format is consistent.
Meanwhile my workaround has been to transform my default config Codable to a Dictionary, using my own encoder configured for iso8601, and use the setDefaults method that accepts dictionaries as parameter instead.
Hope I managed to explain the issue clearly.
Thank you.
Reproducing the issue
No response
Firebase SDK Version
11.7.0
Xcode Version
16.2
Installation Method
Swift Package Manager
Firebase Product(s)
Remote Config
Targeted Platforms
iOS
Relevant Log Output
If using Swift Package Manager, the project's Package.resolved
Expand Package.resolved snippet
Replace this line with the contents of your Package.resolved.
If using CocoaPods, the project's Podfile.lock
Expand Podfile.lock snippet
Replace this line with the contents of your Podfile.lock!
I couldn't figure out how to label this issue, so I've labeled it for a human to triage. Hang tight.
@markos92i Thanks for the report. At first glance it looks clear to me. We'll add it to our backlog.
In the meantime, we'd be happy to review a PR.
I found out that the workaround still goes through the same date parser and still fails :(
Hi @markos92i, sorry for the trouble. At a high level, I think the solution may be that we need to enable customization of the decoder that remote config is using.
However, I don't think I fully understand the order of operations that lead to the failure. Is an error being thrown or is the received date just different than the one stored?
Does this unit test roughly describe the order of operations?
func testSetDefaults_DateParsing_FailsWithCodable() throws {
struct MyDefaults: Codable {
let date: Date
}
// Create codable defaults and call setDefaults
let date = Date()
let myDate = MyDefaults(date: date)
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
let jsonData = try encoder.encode(myDate)
let json = try JSONSerialization.jsonObject(with: jsonData) as! [String: NSObject]
config.setDefaults(json)
// Decode as MyDefaults
let decodedMyDate: MyDefaults = try config.decoded()
// The above call doesn't throw, but the below assertion fails because the dates do not match.
XCTAssertEqual(decodedMyDate.date.timeIntervalSince1970, date.timeIntervalSince1970)
}
I want to ensure I'm able to reproduce the same issue you are describing.
Im gonna paste here the class I use to read the config from firebase.
Whats happening is that the setConfig goes well wether its a codable or dictionary.
But inside the readConfig method when i do try JSONUtils.decode(appVersionsData) it throws and goes to the catch (i have a crashlytics report manualy and thats how I discovered this issue was happening in production)
Im retrieving the data and using my own decoder because otherwise it wont parse the dates in iso 8601 that I have stablished in the firebase remote config console, but I cant control how the default values are stored internally. If i use the default decoder it would decode the default values but not the remote ones... so as you said, being able to customize the decoder would probably fix this issue completely.
import SwiftUI
@preconcurrency import FirebaseRemoteConfig
import FirebaseCrashlytics
@MainActor
@Observable
final class RemoteConfigModel {
private var remoteConfig: RemoteConfig
static let shared = RemoteConfigModel()
var config: RemoteConfigDto = .init()
init() {
remoteConfig = RemoteConfig.remoteConfig()
// Set config
let settings = RemoteConfigSettings()
#if TEST_ENV||PRE_ENV
settings.minimumFetchInterval = 0
#else
settings.minimumFetchInterval = 10
#endif
remoteConfig.configSettings = settings
// Set default values
remoteConfig.setDefaults(config.dictionary as? [String : NSObject])
realTimeUpdates()
}
private func readConfig() -> RemoteConfigDto? {
let appVersionsData: Data = remoteConfig.configValue(forKey: "appVersions").dataValue
let parametersData: Data = remoteConfig.configValue(forKey: "parameters").dataValue
do {
let appVersions: RemoteConfigAppVersionsDto = try JSONUtils.decode(appVersionsData)
let parameters: RemoteConfigParametersDto = try JSONUtils.decode(parametersData)
return .init(appVersions: appVersions, parameters: parameters)
} catch {
let userInfo: [String: Any] = [
"Codable": "\(RemoteConfigDto.self)",
"Data": "\(String(describing: appVersionsData.prettyJson)) \n\(String(describing: parametersData.prettyJson))",
NSLocalizedDescriptionKey: error.localizedDescription,
NSLocalizedFailureReasonErrorKey: "error: \(error)"
]
Crashlytics.crashlytics().record(error: error, userInfo: userInfo)
return nil
}
}
func fetchConfig() async {
do {
let status = try await remoteConfig.fetchAndActivate()
guard status == .successFetchedFromRemote, let result = self.readConfig() else { return }
Task { @MainActor in
Prefs.saveConfig(result)
self.config = result
}
print("Remote Config fetched!")
} catch {
print("Remote Config not fetched: \(error.localizedDescription)")
}
}
func realTimeUpdates() {
remoteConfig.addOnConfigUpdateListener { update, error in
guard let update, error == nil else {
print("Error listening for config updates: \(error!)")
return
}
print("Remote Config updated keys: \(update.updatedKeys)")
Task { @MainActor in
do {
guard try await self.remoteConfig.activate(), let result = self.readConfig() else { return }
Prefs.saveConfig(result)
self.config = result
print("Remote Config update activated")
} catch {
print("Remote Config update not activated: \(error.localizedDescription)")
}
}
}
}
}
Another great addition would be that we could have an async/await alternative to addOnConfigUpdateListener in the same way we have fetchAndActivate() async for example using an AsyncStream<(Values)> like apple does.
You set up an async stream that we can get from remoteConfig
private var update: ((UpdateValue) -> Void)?
var updateStream: AsyncStream<UpdateValue> {
AsyncStream { continuation in
update = { value in continuation.yield(value) }
}
}
Call update(UpdateValue) whenever there is an update Then we could listen to the events doing:
Task {
for await value in remoteConfig.updateStream {
// React to the update
}
}
This would be great to adapt our code fully to async/await and leave callbacks and strict concurrency issues behind.
Thank you.
Hi @markos92i, I pushed a branch https://github.com/firebase/firebase-ios-sdk/tree/nc/14368 with a potential fix that allows you to inject the encoder and decoder to the remote config's public codable APIs. You could try creating a decoder and setting the date decoding strategy, and then passing that decoder into the config.decoded API.
Could you please point to that branch and see if that resolves your issue locally?
I just tested the branch adapting the code to pass my own encoder and decoder and everything seems to work fine now, no errors parsing neither the defaults neither the remote
Here is the new code with the changes, i had to import FirebaseSharedSwift to be able to use FirebaseDataEncoder and FirebaseDataDecoder but other than that it was trivial.
import SwiftUI
@preconcurrency import FirebaseRemoteConfig
@preconcurrency import FirebaseSharedSwift
import FirebaseCrashlytics
@MainActor
@Observable
final class RemoteConfigModel {
private var remoteConfig: RemoteConfig
static let shared = RemoteConfigModel()
var config: RemoteConfigDto = .init()
init() {
remoteConfig = RemoteConfig.remoteConfig()
// Set config
let settings = RemoteConfigSettings()
#if TEST_ENV||PRE_ENV
settings.minimumFetchInterval = 0
#else
settings.minimumFetchInterval = 10
#endif
remoteConfig.configSettings = settings
// Set default values
let encoder = FirebaseDataEncoder()
encoder.dateEncodingStrategy = .iso8601
do {
try remoteConfig.setDefaults(from: config, encoder: encoder)
} catch {
print("ERROR!")
}
realTimeUpdates()
}
private func readConfig() -> RemoteConfigDto? {
let decoder = FirebaseDataDecoder()
decoder.dateDecodingStrategy = .iso8601
do {
return try remoteConfig.decoded(decoder: decoder)
} catch {
let userInfo: [String: Any] = [
"Codable": "\(RemoteConfigDto.self)",
"Data": "\(String(describing: remoteConfig))",
NSLocalizedDescriptionKey: error.localizedDescription,
NSLocalizedFailureReasonErrorKey: "error: \(error)"
]
Crashlytics.crashlytics().record(error: error, userInfo: userInfo)
return nil
}
}
func fetchConfig() async {
do {
let status = try await remoteConfig.fetchAndActivate()
guard status == .successFetchedFromRemote, let result = self.readConfig() else { return }
Task { @MainActor in
Prefs.saveConfig(result)
self.config = result
}
print("Remote Config fetched!")
} catch {
print("Remote Config not fetched: \(error.localizedDescription)")
}
}
func realTimeUpdates() {
remoteConfig.addOnConfigUpdateListener { update, error in
guard let update, error == nil else {
print("Error listening for config updates: \(error!)")
return
}
print("Remote Config updated keys: \(update.updatedKeys)")
Task { @MainActor in
do {
guard try await self.remoteConfig.activate(), let result = self.readConfig() else { return }
Prefs.saveConfig(result)
self.config = result
print("Remote Config update activated")
} catch {
print("Remote Config update not activated: \(error.localizedDescription)")
}
}
}
}
}
Thanks again, @markos92i, for raising this issue. The fix has been merged and will be released in the next Firebase release (est. week of March 17). I'll make a feature request for your previous reply about an async stream listener. We are actively working on improving Swift concurrency support, and that's great feedback.
Hi @markos92i, I'm curious, did you overwrite the https://developer.apple.com/documentation/swift/encodable/encode(to:) and/or https://developer.apple.com/documentation/swift/decodable/init(from:) for your RemoteConfigDto type? I'm curious if the original error thrown was defined in one of these methods, and surfaced during the Remote Config SDK's decoding of your type?
I created an auxiliary struct to ensure I used the same config for the encoder and decoder everywhere like this:
struct JSONUtils {
static func encode<T: Encodable>(_ value: T) throws -> Data {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
return try encoder.encode(value)
}
static func decode<R: Decodable>(_ value: Data) throws -> R {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return try decoder.decode(R.self, from: value)
}
}
And thats how I found out when the default config was setted up with your encoder, and then decoded back with mine i saw errors when parsing the dates and it caught my attention. Finding out it worked if the config was retrived from the network and failing with the defaults.
Thanks, @markos92i. So RemoteConfigDto is using the synthesized Codable implementations not overwritten ones.
Yes RemoteConfigDto is just a simple struct without anything special here is the full code:
import Foundation
struct RemoteConfigDto: Codable, Sendable, Equatable {
var appVersions: RemoteConfigAppVersionsDto = .init()
var parameters: RemoteConfigParametersDto = .init()
}
struct RemoteConfigParametersDto: Codable, Sendable, Equatable {
var dates: DatesConfig = .init()
var enablements: EnablementsConfig = .init()
var message: MessageConfig = .init()
var newFeatures: NewFeaturesConfig = .init()
var security: SecurityConfig = .init()
var urls: URLsConfig = .init()
}
// MARK: - AppVersions
struct RemoteConfigAppVersionsDto: Codable, Sendable, Equatable {
var required: String = Constants.appVersion
var recommended: String = Constants.appVersion
var os: String = Constants.systemVersion
}
extension RemoteConfigAppVersionsDto {
// If first value is 1.0.0 and second 1.0.1 -> returns .orderedAscending
var isRequired: Bool { Constants.appVersion.versionCompare(required) == .orderedAscending }
var isRecommended: Bool { Constants.appVersion.versionCompare(recommended) == .orderedAscending }
var isSystemUpdate: Bool { Constants.systemVersion.versionCompare(os) == .orderedAscending }
}
// MARK: - Dates
struct DatesConfig: Codable, Sendable, Equatable {
var privacyPolicyTerms: Date = "2022-02-10Z".toDate(format: "yyyy-MM-dd'Z'")
var legalTerms: Date = "2022-02-10Z".toDate(format: "yyyy-MM-dd'Z'")
var masterCache: Date = "2022-02-10Z".toDate(format: "yyyy-MM-dd'Z'")
}
// MARK: - Enablements
struct EnablementsConfig: Codable, Sendable, Equatable {
var app: Bool = true
var adn: Bool = true
var callings: Bool = true
var schedule: Bool = true
}
// MARK: - ConfigMessage
struct MessageConfig: Codable, Sendable, Equatable {
var visible: Bool = false
var title: String = ""
var message: String = ""
}
// MARK: - NewFeatures
struct NewFeaturesConfig: Codable, Sendable, Equatable {
var residenceCountries: [KeyValueDto] = [.init(id: "11", description: "España")]
}
// MARK: - SecurityConfig
struct SecurityConfig: Codable, Sendable, Equatable {
var spar: String = ""
}
// MARK: - URLsConfig
struct URLsConfig: Codable, Sendable, Equatable {
var docSignMaintenance: String? = nil
}
Thanks again, @markos92i, for raising this issue. The fix has been merged and will be released in the next Firebase release (est. week of March 17). I'll make a feature request for your previous reply about an async stream listener. We are actively working on improving Swift concurrency support, and that's great feedback.
Hi @markos92i, there's been a change to my previous comment. There were some challenges to the original fix I proposed, so it's been pulled from the upcoming release. For now, I believe you can fix the issue without an update from Firebase by manually implementing the Codable APIs for the struct that stores dates, DatesConfig, to read/write dates as iso8601 strings.
struct DatesConfig: Codable, Sendable, Equatable {
var privacyPolicyTerms: Date = "2022-02-10Z".toDate(format: "yyyy-MM-dd'Z'")
var legalTerms: Date = "2022-02-10Z".toDate(format: "yyyy-MM-dd'Z'")
var masterCache: Date = "2022-02-10Z".toDate(format: "yyyy-MM-dd'Z'")
enum CodingKeys: CodingKey {
case privacyPolicyTerms
case legalTerms
case masterCache
}
// Note: `Date.ISO8601FormatStyle()` is iOS 15.0+. If support for an older deployment
// verssion is needed, use `ISO8601DateFormatter`.
private static let formatter = Date.ISO8601FormatStyle()
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
// 1. This is what Xcode synthesizes. For RC however, the dates are
// represented in the Firebase console as iso8601 `String`s.
// self.privacyPolicyTerms = try container.decode(Date.self, forKey: .privacyPolicyTerms)
// self.legalTerms = try container.decode(Date.self, forKey: .legalTerms)
// self.masterCache = try container.decode(Date.self, forKey: .masterCache)
// 2. Instead, decode them as `String`s and parse them as iso8601 dates.
if let privacyPolicyTerms = try? container.decode(String.self, forKey: .privacyPolicyTerms) {
self.privacyPolicyTerms = try Self.formatter.parse(privacyPolicyTerms)
}
if let legalTerms = try? container.decode(String.self, forKey: .legalTerms) {
self.legalTerms = try Self.formatter.parse(legalTerms)
}
if let masterCache = try? container.decode(String.self, forKey: .masterCache) {
self.masterCache = try Self.formatter.parse(masterCache)
}
}
func encode(to encoder: any Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
// 3. Encode dates as iso8601 strings.
try container.encode(Self.formatter.format(privacyPolicyTerms), forKey: .privacyPolicyTerms)
try container.encode(Self.formatter.format(legalTerms), forKey: .legalTerms)
try container.encode(Self.formatter.format(masterCache), forKey: .masterCache)
}
}