firebase-ios-sdk icon indicating copy to clipboard operation
firebase-ios-sdk copied to clipboard

FirebaseRemoteConfig issue with Codable default config

Open markos92i opened this issue 10 months ago • 2 comments

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!

markos92i avatar Jan 22 '25 12:01 markos92i

I couldn't figure out how to label this issue, so I've labeled it for a human to triage. Hang tight.

google-oss-bot avatar Jan 22 '25 12:01 google-oss-bot

@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.

paulb777 avatar Jan 23 '25 20:01 paulb777

I found out that the workaround still goes through the same date parser and still fails :(

markos92i avatar Feb 27 '25 10:02 markos92i

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.

ncooke3 avatar Mar 01 '25 01:03 ncooke3

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)")
                }
            }
        }
    }
}

markos92i avatar Mar 01 '25 11:03 markos92i

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.

markos92i avatar Mar 01 '25 11:03 markos92i

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?

ncooke3 avatar Mar 04 '25 02:03 ncooke3

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)")
                }
            }
        }
    }
}

markos92i avatar Mar 04 '25 12:03 markos92i

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.

ncooke3 avatar Mar 06 '25 23:03 ncooke3

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?

ncooke3 avatar Mar 10 '25 21:03 ncooke3

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.

markos92i avatar Mar 10 '25 22:03 markos92i

Thanks, @markos92i. So RemoteConfigDto is using the synthesized Codable implementations not overwritten ones.

ncooke3 avatar Mar 10 '25 22:03 ncooke3

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
}

markos92i avatar Mar 10 '25 22:03 markos92i

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)
  }
}

ncooke3 avatar Mar 11 '25 22:03 ncooke3