From b8a140cacda39b78fe95c6e0468f01849f9090e8 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Wed, 21 Oct 2026 13:21:17 +0200 Subject: [PATCH 1/3] fix(remote_config): deliver onConfigUpdated errors to Dart on Android and iOS On Android the realtime listener called EventSink.error from the SDK's background executor. FlutterJNI throws off the main thread, so the error never reached Dart and the exception was swallowed by the SDK's task continuation. Post it to the main thread, as onUpdate already does. On iOS the error was only printed. Forward it to the stream as a FlutterError with the same code as Android, matching web, which already adds realtime errors to the stream. --- .github/workflows/android_unit_tests.yaml | 2 +- .../android/build.gradle | 5 ++ .../FirebaseRemoteConfigPlugin.kt | 33 +++++--- .../FirebaseRemoteConfigPluginTest.kt | 83 +++++++++++++++++++ .../FirebaseRemoteConfigPlugin.swift | 9 +- 5 files changed, 120 insertions(+), 12 deletions(-) create mode 100644 packages/firebase_remote_config/firebase_remote_config/android/src/test/kotlin/io/flutter/plugins/firebase/firebaseremoteconfig/FirebaseRemoteConfigPluginTest.kt diff --git a/.github/workflows/android_unit_tests.yaml b/.github/workflows/android_unit_tests.yaml index 458257cfa3f0..3d701ccc36fc 100644 --- a/.github/workflows/android_unit_tests.yaml +++ b/.github/workflows/android_unit_tests.yaml @@ -43,7 +43,7 @@ jobs: # Tasks are listed per package rather than using the root testDebugUnitTest # task, which would also build the Dart sources of the aggregate test app. # Add a task here when a package gains an android/src/test directory. - run: cd tests/android && ./gradlew :firebase_crashlytics:testDebugUnitTest :cloud_functions:testDebugUnitTest + run: cd tests/android && ./gradlew :firebase_crashlytics:testDebugUnitTest :cloud_functions:testDebugUnitTest :firebase_remote_config:testDebugUnitTest - name: 'Upload test reports' if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a diff --git a/packages/firebase_remote_config/firebase_remote_config/android/build.gradle b/packages/firebase_remote_config/firebase_remote_config/android/build.gradle index 77b73d3605d0..fa87c1196260 100644 --- a/packages/firebase_remote_config/firebase_remote_config/android/build.gradle +++ b/packages/firebase_remote_config/firebase_remote_config/android/build.gradle @@ -72,11 +72,16 @@ android { disable 'InvalidPackage' } + testOptions { + unitTests.returnDefaultValues = true + } + dependencies { api firebaseCoreProject implementation platform("com.google.firebase:firebase-bom:${getRootProjectExtOrCoreProperty("FirebaseSDKVersion", firebaseCoreProject)}") implementation 'com.google.firebase:firebase-config' implementation 'androidx.annotation:annotation:1.7.0' + testImplementation 'junit:junit:4.13.2' } } diff --git a/packages/firebase_remote_config/firebase_remote_config/android/src/main/kotlin/io/flutter/plugins/firebase/firebaseremoteconfig/FirebaseRemoteConfigPlugin.kt b/packages/firebase_remote_config/firebase_remote_config/android/src/main/kotlin/io/flutter/plugins/firebase/firebaseremoteconfig/FirebaseRemoteConfigPlugin.kt index ee67d2736561..6ac90d87d123 100644 --- a/packages/firebase_remote_config/firebase_remote_config/android/src/main/kotlin/io/flutter/plugins/firebase/firebaseremoteconfig/FirebaseRemoteConfigPlugin.kt +++ b/packages/firebase_remote_config/firebase_remote_config/android/src/main/kotlin/io/flutter/plugins/firebase/firebaseremoteconfig/FirebaseRemoteConfigPlugin.kt @@ -28,6 +28,7 @@ import io.flutter.plugin.common.EventChannel.EventSink import io.flutter.plugins.firebase.core.FlutterFirebasePlugin import io.flutter.plugins.firebase.core.FlutterFirebasePluginRegistry import java.util.Objects +import java.util.concurrent.Executor /** FirebaseRemoteConfigPlugin */ class FirebaseRemoteConfigPlugin : @@ -189,16 +190,7 @@ class FirebaseRemoteConfigPlugin : listenersMap[appName] = remoteConfig.addOnConfigUpdateListener( - object : ConfigUpdateListener { - override fun onUpdate(configUpdate: ConfigUpdate) { - val updatedKeys = ArrayList(configUpdate.updatedKeys) - mainThreadHandler.post { events.success(updatedKeys) } - } - - override fun onError(error: FirebaseRemoteConfigException) { - events.error("firebase_remote_config", error.message, null) - } - }) + createConfigUpdateListener(events) { mainThreadHandler.post(it) }) } override fun onCancel(arguments: Any?) { @@ -256,6 +248,27 @@ class FirebaseRemoteConfigPlugin : const val TAG: String = "FRCPlugin" const val METHOD_CHANNEL: String = "plugins.flutter.io/firebase_remote_config" const val EVENT_CHANNEL: String = "plugins.flutter.io/firebase_remote_config_updated" + + /** + * The SDK calls both callbacks on its own background executor, but an [EventSink] may only be + * used on the main thread, so both go through [mainThreadExecutor]. + */ + internal fun createConfigUpdateListener( + events: EventSink, + mainThreadExecutor: Executor, + ): ConfigUpdateListener = + object : ConfigUpdateListener { + override fun onUpdate(configUpdate: ConfigUpdate) { + val updatedKeys = ArrayList(configUpdate.updatedKeys) + mainThreadExecutor.execute { events.success(updatedKeys) } + } + + override fun onError(error: FirebaseRemoteConfigException) { + mainThreadExecutor.execute { + events.error("firebase_remote_config", error.message, null) + } + } + } } override fun fetch(appName: String, callback: (Result) -> Unit) { diff --git a/packages/firebase_remote_config/firebase_remote_config/android/src/test/kotlin/io/flutter/plugins/firebase/firebaseremoteconfig/FirebaseRemoteConfigPluginTest.kt b/packages/firebase_remote_config/firebase_remote_config/android/src/test/kotlin/io/flutter/plugins/firebase/firebaseremoteconfig/FirebaseRemoteConfigPluginTest.kt new file mode 100644 index 000000000000..a7c5764f39ad --- /dev/null +++ b/packages/firebase_remote_config/firebase_remote_config/android/src/test/kotlin/io/flutter/plugins/firebase/firebaseremoteconfig/FirebaseRemoteConfigPluginTest.kt @@ -0,0 +1,83 @@ +// Copyright 2026 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +package io.flutter.plugins.firebase.firebaseremoteconfig + +import com.google.firebase.remoteconfig.ConfigUpdate +import com.google.firebase.remoteconfig.FirebaseRemoteConfigException +import io.flutter.plugin.common.EventChannel.EventSink +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +class FirebaseRemoteConfigPluginTest { + @Volatile private var mainThread: Thread? = null + private val mainThreadExecutor: ExecutorService = + Executors.newSingleThreadExecutor { runnable -> Thread(runnable).also { mainThread = it } } + + /** An [EventSink] that, like FlutterJNI, throws when it is used off the main thread. */ + private inner class MainThreadOnlyEventSink : EventSink { + val received = LinkedBlockingQueue() + + private fun ensureRunningOnMainThread() { + if (Thread.currentThread() !== mainThread) { + throw RuntimeException( + "Methods marked with @UiThread must be executed on the main thread. Current thread: " + + Thread.currentThread().name) + } + } + + override fun success(event: Any?) { + ensureRunningOnMainThread() + received.add("success $event") + } + + override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) { + ensureRunningOnMainThread() + received.add("error $errorCode: $errorMessage") + } + + override fun endOfStream() { + ensureRunningOnMainThread() + received.add("endOfStream") + } + } + + private val events = MainThreadOnlyEventSink() + + @After + fun tearDown() { + mainThreadExecutor.shutdownNow() + } + + @Test + fun onError_isForwardedToDartOnTheMainThread() { + val listener = FirebaseRemoteConfigPlugin.createConfigUpdateListener(events, mainThreadExecutor) + + // The SDK reports realtime errors from its own background executor, which is this test thread. + listener.onError( + FirebaseRemoteConfigException( + "The connection to the realtime stream failed.", + FirebaseRemoteConfigException.Code.CONFIG_UPDATE_STREAM_ERROR)) + + assertEquals( + "error firebase_remote_config: The connection to the realtime stream failed.", + events.received.poll(5, TimeUnit.SECONDS)) + } + + @Test + fun onUpdate_isForwardedToDartOnTheMainThread() { + val listener = FirebaseRemoteConfigPlugin.createConfigUpdateListener(events, mainThreadExecutor) + + listener.onUpdate(ConfigUpdate.create(setOf("welcome_message"))) + + assertEquals("success [welcome_message]", events.received.poll(5, TimeUnit.SECONDS)) + } +} diff --git a/packages/firebase_remote_config/firebase_remote_config/ios/firebase_remote_config/Sources/firebase_remote_config/FirebaseRemoteConfigPlugin.swift b/packages/firebase_remote_config/firebase_remote_config/ios/firebase_remote_config/Sources/firebase_remote_config/FirebaseRemoteConfigPlugin.swift index 59856a16fa83..f1bdb727f87a 100644 --- a/packages/firebase_remote_config/firebase_remote_config/ios/firebase_remote_config/Sources/firebase_remote_config/FirebaseRemoteConfigPlugin.swift +++ b/packages/firebase_remote_config/firebase_remote_config/ios/firebase_remote_config/Sources/firebase_remote_config/FirebaseRemoteConfigPlugin.swift @@ -207,7 +207,14 @@ public class FirebaseRemoteConfigPlugin: NSObject, FlutterPlugin, FlutterStreamH let remoteConfig = getRemoteConfig(from: appName) listenersMap[appName] = remoteConfig.addOnConfigUpdateListener { update, error in if let error { - print("Remote Config update error: \(error.localizedDescription)") + DispatchQueue.main.async { + events( + FlutterError( + code: "firebase_remote_config", + message: error.localizedDescription, + details: nil + )) + } return } if let update { From 95757a3982776e90adae66c3c6433d6f0564d5c3 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Wed, 21 Oct 2026 15:42:08 +0200 Subject: [PATCH 2/3] fix(remote_config): report onConfigUpdated errors as FirebaseException with stable codes onConfigUpdated now reports realtime connection errors on Android and iOS; listen with onError. An error with no handler is reported as uncaught, which Crashlytics records as a fatal crash. The stream stays open after an error, and the SDKs keep retrying, so the same error can be reported again. Every platform now delivers these errors as FirebaseException(plugin: 'firebase_remote_config') with one of these codes: config-update-stream-error, config-update-message-invalid, config-update-not-fetched, config-update-unavailable or unknown. Before, Android and iOS sent a PlatformException with no details, which lost the native code, and web sent the raw JS FirebaseError. - Android and iOS put the code and message in the error details, and the method channel stream converts them with the package's existing convertPlatformException. - On iOS, the update error domain is checked first. Its codes overlap with RemoteConfigError (notFetched and throttled are both 8002). A failed fetch of an announced update keeps the codes fetch uses. - On web, onConfigUpdated goes through convertWebExceptions, which now maps the JS SDK's realtime codes to the same names. The onConfigUpdated dartdoc lists the codes. The docs snippet and the example now pass onError. --- docs/remote-config/_get-started.md | 8 ++ .../FirebaseRemoteConfigPlugin.kt | 39 +++++- .../FirebaseRemoteConfigPluginTest.kt | 91 ++++++++++++- .../example/lib/home_page.dart | 22 +-- .../FirebaseRemoteConfigPlugin.swift | 5 +- .../FirebaseRemoteConfigUtils.swift | 29 ++++ .../lib/src/firebase_remote_config.dart | 30 +++++ ...method_channel_firebase_remote_config.dart | 8 +- ...form_interface_firebase_remote_config.dart | 3 + ...d_channel_firebase_remote_config_test.dart | 126 ++++++++++++++++++ .../lib/firebase_remote_config_web.dart | 6 +- .../lib/src/internals.dart | 13 +- .../test/internals_test.dart | 65 +++++++++ 13 files changed, 418 insertions(+), 27 deletions(-) create mode 100644 packages/firebase_remote_config/firebase_remote_config_web/test/internals_test.dart diff --git a/docs/remote-config/_get-started.md b/docs/remote-config/_get-started.md index c68580ebe438..54ec5e022356 100644 --- a/docs/remote-config/_get-started.md +++ b/docs/remote-config/_get-started.md @@ -159,9 +159,17 @@ Note that real-time Remote Config is not available for Web. await remoteConfig.activate(); // Use the new config values here. + }, onError: (error) { + // The real-time connection failed. The stream stays open and the SDK + // retries, so this can be called more than once. }); ``` + Always pass `onError`. Real-time connection errors are added to the stream + as a `FirebaseException`, with a code such as `config-update-stream-error`, + and an error with no handler is reported as uncaught, which Crashlytics + records as a fatal crash. + 2. The next time you publish a new version of your Remote Config, devices that are running your app and listening for changes will activate the new config. diff --git a/packages/firebase_remote_config/firebase_remote_config/android/src/main/kotlin/io/flutter/plugins/firebase/firebaseremoteconfig/FirebaseRemoteConfigPlugin.kt b/packages/firebase_remote_config/firebase_remote_config/android/src/main/kotlin/io/flutter/plugins/firebase/firebaseremoteconfig/FirebaseRemoteConfigPlugin.kt index 6ac90d87d123..d7ec3efd459c 100644 --- a/packages/firebase_remote_config/firebase_remote_config/android/src/main/kotlin/io/flutter/plugins/firebase/firebaseremoteconfig/FirebaseRemoteConfigPlugin.kt +++ b/packages/firebase_remote_config/firebase_remote_config/android/src/main/kotlin/io/flutter/plugins/firebase/firebaseremoteconfig/FirebaseRemoteConfigPlugin.kt @@ -31,12 +31,23 @@ import java.util.Objects import java.util.concurrent.Executor /** FirebaseRemoteConfigPlugin */ -class FirebaseRemoteConfigPlugin : - FlutterFirebasePlugin, FlutterPlugin, EventChannel.StreamHandler, FirebaseRemoteConfigHostApi { +class FirebaseRemoteConfigPlugin +internal constructor( + private val mainThreadExecutor: Executor, + private val addConfigUpdateListener: + (appName: String, listener: ConfigUpdateListener) -> ConfigUpdateListenerRegistration, +) : FlutterFirebasePlugin, FlutterPlugin, EventChannel.StreamHandler, FirebaseRemoteConfigHostApi { + + constructor() : + this( + Handler(Looper.getMainLooper()).let { handler -> Executor { handler.post(it) } }, + { appName, listener -> + FirebaseRemoteConfig.getInstance(FirebaseApp.getInstance(appName)) + .addOnConfigUpdateListener(listener) + }) private val listenersMap: MutableMap = HashMap() private var eventChannel: EventChannel? = null - private val mainThreadHandler = Handler(Looper.getMainLooper()) private var messenger: BinaryMessenger? = null override fun onAttachedToEngine(binding: FlutterPluginBinding) { @@ -186,11 +197,9 @@ class FirebaseRemoteConfigPlugin : override fun onListen(arguments: Any, events: EventSink) { val argumentsMap = arguments as Map val appName = Objects.requireNonNull(argumentsMap["appName"]) as String - val remoteConfig = getRemoteConfig(appName) listenersMap[appName] = - remoteConfig.addOnConfigUpdateListener( - createConfigUpdateListener(events) { mainThreadHandler.post(it) }) + addConfigUpdateListener(appName, createConfigUpdateListener(events, mainThreadExecutor)) } override fun onCancel(arguments: Any?) { @@ -264,11 +273,27 @@ class FirebaseRemoteConfigPlugin : } override fun onError(error: FirebaseRemoteConfigException) { + val details = + mapOf("code" to configUpdateErrorCode(error.code), "message" to error.message) mainThreadExecutor.execute { - events.error("firebase_remote_config", error.message, null) + events.error("firebase_remote_config", error.message, details) } } } + + /** The code Dart reports a realtime update error with, the same on every platform. */ + private fun configUpdateErrorCode(code: FirebaseRemoteConfigException.Code): String = + when (code) { + FirebaseRemoteConfigException.Code.CONFIG_UPDATE_STREAM_ERROR -> + "config-update-stream-error" + FirebaseRemoteConfigException.Code.CONFIG_UPDATE_MESSAGE_INVALID -> + "config-update-message-invalid" + FirebaseRemoteConfigException.Code.CONFIG_UPDATE_NOT_FETCHED -> + "config-update-not-fetched" + FirebaseRemoteConfigException.Code.CONFIG_UPDATE_UNAVAILABLE -> + "config-update-unavailable" + else -> "unknown" + } } override fun fetch(appName: String, callback: (Result) -> Unit) { diff --git a/packages/firebase_remote_config/firebase_remote_config/android/src/test/kotlin/io/flutter/plugins/firebase/firebaseremoteconfig/FirebaseRemoteConfigPluginTest.kt b/packages/firebase_remote_config/firebase_remote_config/android/src/test/kotlin/io/flutter/plugins/firebase/firebaseremoteconfig/FirebaseRemoteConfigPluginTest.kt index a7c5764f39ad..b2f0081d9835 100644 --- a/packages/firebase_remote_config/firebase_remote_config/android/src/test/kotlin/io/flutter/plugins/firebase/firebaseremoteconfig/FirebaseRemoteConfigPluginTest.kt +++ b/packages/firebase_remote_config/firebase_remote_config/android/src/test/kotlin/io/flutter/plugins/firebase/firebaseremoteconfig/FirebaseRemoteConfigPluginTest.kt @@ -4,7 +4,10 @@ package io.flutter.plugins.firebase.firebaseremoteconfig import com.google.firebase.remoteconfig.ConfigUpdate +import com.google.firebase.remoteconfig.ConfigUpdateListener +import com.google.firebase.remoteconfig.ConfigUpdateListenerRegistration import com.google.firebase.remoteconfig.FirebaseRemoteConfigException +import com.google.firebase.remoteconfig.FirebaseRemoteConfigServerException import io.flutter.plugin.common.EventChannel.EventSink import java.util.concurrent.ExecutorService import java.util.concurrent.Executors @@ -12,6 +15,8 @@ import java.util.concurrent.LinkedBlockingQueue import java.util.concurrent.TimeUnit import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith import org.junit.runners.JUnit4 @@ -41,7 +46,7 @@ class FirebaseRemoteConfigPluginTest { override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) { ensureRunningOnMainThread() - received.add("error $errorCode: $errorMessage") + received.add("error $errorCode: $errorMessage $errorDetails") } override fun endOfStream() { @@ -57,6 +62,8 @@ class FirebaseRemoteConfigPluginTest { mainThreadExecutor.shutdownNow() } + private fun nextEvent(): String? = events.received.poll(5, TimeUnit.SECONDS) + @Test fun onError_isForwardedToDartOnTheMainThread() { val listener = FirebaseRemoteConfigPlugin.createConfigUpdateListener(events, mainThreadExecutor) @@ -68,8 +75,51 @@ class FirebaseRemoteConfigPluginTest { FirebaseRemoteConfigException.Code.CONFIG_UPDATE_STREAM_ERROR)) assertEquals( - "error firebase_remote_config: The connection to the realtime stream failed.", - events.received.poll(5, TimeUnit.SECONDS)) + "error firebase_remote_config: The connection to the realtime stream failed. " + + "{code=config-update-stream-error, " + + "message=The connection to the realtime stream failed.}", + nextEvent()) + } + + @Test + fun onError_reportsAStableCodeForEachNativeCode() { + val listener = FirebaseRemoteConfigPlugin.createConfigUpdateListener(events, mainThreadExecutor) + val expectedCodes = + mapOf( + FirebaseRemoteConfigException.Code.CONFIG_UPDATE_STREAM_ERROR to + "config-update-stream-error", + FirebaseRemoteConfigException.Code.CONFIG_UPDATE_MESSAGE_INVALID to + "config-update-message-invalid", + FirebaseRemoteConfigException.Code.CONFIG_UPDATE_NOT_FETCHED to + "config-update-not-fetched", + FirebaseRemoteConfigException.Code.CONFIG_UPDATE_UNAVAILABLE to + "config-update-unavailable", + FirebaseRemoteConfigException.Code.UNKNOWN to "unknown", + ) + assertEquals(FirebaseRemoteConfigException.Code.values().toSet(), expectedCodes.keys) + + for ((nativeCode, code) in expectedCodes) { + listener.onError(FirebaseRemoteConfigException("message", nativeCode)) + + assertEquals( + "error firebase_remote_config: message {code=$code, message=message}", nextEvent()) + } + } + + @Test + fun onError_keepsTheHttpStatusInTheMessage() { + val listener = FirebaseRemoteConfigPlugin.createConfigUpdateListener(events, mainThreadExecutor) + + listener.onError( + FirebaseRemoteConfigServerException( + 503, + "Unable to connect to the server. Try again in a few minutes. HTTP status code: 503", + FirebaseRemoteConfigException.Code.CONFIG_UPDATE_STREAM_ERROR)) + + val event = nextEvent() + assertNotNull(event) + assertTrue(event!!, event.contains("code=config-update-stream-error")) + assertTrue(event, event.contains("message=Unable to connect") && event.endsWith("503}")) } @Test @@ -78,6 +128,39 @@ class FirebaseRemoteConfigPluginTest { listener.onUpdate(ConfigUpdate.create(setOf("welcome_message"))) - assertEquals("success [welcome_message]", events.received.poll(5, TimeUnit.SECONDS)) + assertEquals("success [welcome_message]", nextEvent()) + } + + @Test + fun onListen_registersAListenerThatReportsOnTheMainThread() { + val registered = LinkedBlockingQueue>() + var removed = false + val plugin = + FirebaseRemoteConfigPlugin(mainThreadExecutor) { appName, listener -> + registered.add(appName to listener) + ConfigUpdateListenerRegistration { removed = true } + } + + plugin.onListen(mapOf("appName" to "[DEFAULT]"), events) + + val (appName, listener) = registered.single() + assertEquals("[DEFAULT]", appName) + + // Both callbacks come from the SDK's background executor, which is this test thread. + listener.onError( + FirebaseRemoteConfigException( + "Unable to fetch the latest version of the template.", + FirebaseRemoteConfigException.Code.CONFIG_UPDATE_NOT_FETCHED)) + listener.onUpdate(ConfigUpdate.create(setOf("welcome_message"))) + + assertEquals( + "error firebase_remote_config: Unable to fetch the latest version of the template. " + + "{code=config-update-not-fetched, " + + "message=Unable to fetch the latest version of the template.}", + nextEvent()) + assertEquals("success [welcome_message]", nextEvent()) + + plugin.onCancel(mapOf("appName" to "[DEFAULT]")) + assertTrue(removed) } } diff --git a/packages/firebase_remote_config/firebase_remote_config/example/lib/home_page.dart b/packages/firebase_remote_config/firebase_remote_config/example/lib/home_page.dart index dacdfdc8c61a..33c0a1659e32 100644 --- a/packages/firebase_remote_config/firebase_remote_config/example/lib/home_page.dart +++ b/packages/firebase_remote_config/firebase_remote_config/example/lib/home_page.dart @@ -110,15 +110,21 @@ class _HomePageState extends State { return 'Listening cancelled'; } setState(() { - subscription = - remoteConfig.onConfigUpdated.listen((event) async { - // Make new values available to the app. - await remoteConfig.activate(); + subscription = remoteConfig.onConfigUpdated.listen( + (event) async { + // Make new values available to the app. + await remoteConfig.activate(); - setState(() { - update = event; - }); - }); + setState(() { + update = event; + }); + }, + // Realtime connection errors. The stream stays open and + // the SDK retries, so this can be called more than once. + onError: (Object error) { + print('Realtime update error: $error'); + }, + ); }); return 'Listening, waiting for update...'; diff --git a/packages/firebase_remote_config/firebase_remote_config/ios/firebase_remote_config/Sources/firebase_remote_config/FirebaseRemoteConfigPlugin.swift b/packages/firebase_remote_config/firebase_remote_config/ios/firebase_remote_config/Sources/firebase_remote_config/FirebaseRemoteConfigPlugin.swift index f1bdb727f87a..7d315318e547 100644 --- a/packages/firebase_remote_config/firebase_remote_config/ios/firebase_remote_config/Sources/firebase_remote_config/FirebaseRemoteConfigPlugin.swift +++ b/packages/firebase_remote_config/firebase_remote_config/ios/firebase_remote_config/Sources/firebase_remote_config/FirebaseRemoteConfigPlugin.swift @@ -207,12 +207,13 @@ public class FirebaseRemoteConfigPlugin: NSObject, FlutterPlugin, FlutterStreamH let remoteConfig = getRemoteConfig(from: appName) listenersMap[appName] = remoteConfig.addOnConfigUpdateListener { update, error in if let error { + let nsError = error as NSError DispatchQueue.main.async { events( FlutterError( code: "firebase_remote_config", - message: error.localizedDescription, - details: nil + message: nsError.localizedDescription, + details: FLTFirebaseRemoteConfigUtils.configUpdateErrorCodeAndMessage(from: nsError) )) } return diff --git a/packages/firebase_remote_config/firebase_remote_config/ios/firebase_remote_config/Sources/firebase_remote_config/FirebaseRemoteConfigUtils.swift b/packages/firebase_remote_config/firebase_remote_config/ios/firebase_remote_config/Sources/firebase_remote_config/FirebaseRemoteConfigUtils.swift index 45065769736b..0b2d0d64f1c3 100644 --- a/packages/firebase_remote_config/firebase_remote_config/ios/firebase_remote_config/Sources/firebase_remote_config/FirebaseRemoteConfigUtils.swift +++ b/packages/firebase_remote_config/firebase_remote_config/ios/firebase_remote_config/Sources/firebase_remote_config/FirebaseRemoteConfigUtils.swift @@ -36,4 +36,33 @@ class FLTFirebaseRemoteConfigUtils { return codeAndMessage } + + /// The code and message Dart reports a realtime update error with, the same as on Android. + /// + /// Update errors have their own domain, whose codes overlap with `RemoteConfigError`: + /// `notFetched` and `throttled` are both 8002, so the domain must be checked first. + static func configUpdateErrorCodeAndMessage(from error: NSError) -> [String: String] { + // A failed fetch of an announced update is reported with the fetch error itself. + if error.domain == RemoteConfigErrorDomain { + return errorCodeAndMessage(from: error) + } + + var code = "unknown" + if error.domain == RemoteConfigUpdateErrorDomain { + switch error.code { + case RemoteConfigUpdateError.streamError.rawValue: + code = "config-update-stream-error" + case RemoteConfigUpdateError.messageInvalid.rawValue: + code = "config-update-message-invalid" + case RemoteConfigUpdateError.notFetched.rawValue: + code = "config-update-not-fetched" + case RemoteConfigUpdateError.unavailable.rawValue: + code = "config-update-unavailable" + default: + break + } + } + + return ["code": code, "message": error.localizedDescription] + } } diff --git a/packages/firebase_remote_config/firebase_remote_config/lib/src/firebase_remote_config.dart b/packages/firebase_remote_config/firebase_remote_config/lib/src/firebase_remote_config.dart index cd63529b539f..47a3b3f54796 100644 --- a/packages/firebase_remote_config/firebase_remote_config/lib/src/firebase_remote_config.dart +++ b/packages/firebase_remote_config/firebase_remote_config/lib/src/firebase_remote_config.dart @@ -191,6 +191,36 @@ class FirebaseRemoteConfig extends FirebasePlugin { /// If a connection to the Remote Config backend is not already open, calling this method will /// open it. Multiple listeners can be added by calling this method again, but subsequent calls /// reuse the same connection to the backend. + /// + /// Errors from the real-time connection are added to the stream as a [FirebaseException] with + /// one of these codes: + /// + /// * `config-update-stream-error`: the connection to the backend failed. The message includes + /// the HTTP status, if there is one. + /// * `config-update-message-invalid`: an update message from the backend could not be parsed. + /// * `config-update-not-fetched`: an update was announced, but the new config could not be + /// fetched. + /// * `config-update-unavailable`: the real-time service is temporarily unavailable. + /// * `unknown`: any other error. + /// + /// On iOS, a failed fetch of an announced update can instead be reported with the codes [fetch] + /// uses, such as `throttled`. + /// + /// The stream stays open after an error, and the SDK keeps retrying, so the same error can be + /// reported again, for example each time the app returns to the foreground. Always pass an + /// `onError` callback to [Stream.listen]: without one, an error is reported as uncaught, which + /// crash reporters such as Crashlytics record as a fatal crash. + /// + /// ```dart + /// remoteConfig.onConfigUpdated.listen( + /// (update) async { + /// await remoteConfig.activate(); + /// }, + /// onError: (Object error) { + /// // The config keeps its last activated values. + /// }, + /// ); + /// ``` Stream get onConfigUpdated { return _delegate.onConfigUpdated; } diff --git a/packages/firebase_remote_config/firebase_remote_config_platform_interface/lib/src/method_channel/method_channel_firebase_remote_config.dart b/packages/firebase_remote_config/firebase_remote_config_platform_interface/lib/src/method_channel/method_channel_firebase_remote_config.dart index c7a072189d78..147a612dd3de 100644 --- a/packages/firebase_remote_config/firebase_remote_config_platform_interface/lib/src/method_channel/method_channel_firebase_remote_config.dart +++ b/packages/firebase_remote_config/firebase_remote_config_platform_interface/lib/src/method_channel/method_channel_firebase_remote_config.dart @@ -5,6 +5,7 @@ // ignore_for_file: require_trailing_commas import 'dart:async'; +import 'package:_flutterfire_internals/_flutterfire_internals.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_remote_config_platform_interface/src/pigeon/messages.pigeon.dart'; import 'package:flutter/services.dart'; @@ -283,9 +284,10 @@ class MethodChannelFirebaseRemoteConfig extends FirebaseRemoteConfigPlatform { @override Stream get onConfigUpdated { _onConfigUpdatedStream ??= - _eventChannelConfigUpdated.receiveBroadcastStream({ - 'appName': app.name, - }).map((event) { + _eventChannelConfigUpdated.receiveGuardedBroadcastStream( + arguments: {'appName': app.name}, + onError: convertPlatformException, + ).map((event) { final updatedKeys = Set.from(event); return RemoteConfigUpdate(updatedKeys); }); diff --git a/packages/firebase_remote_config/firebase_remote_config_platform_interface/lib/src/platform_interface/platform_interface_firebase_remote_config.dart b/packages/firebase_remote_config/firebase_remote_config_platform_interface/lib/src/platform_interface/platform_interface_firebase_remote_config.dart index 254a2fb2511d..a252689f2f26 100644 --- a/packages/firebase_remote_config/firebase_remote_config_platform_interface/lib/src/platform_interface/platform_interface_firebase_remote_config.dart +++ b/packages/firebase_remote_config/firebase_remote_config_platform_interface/lib/src/platform_interface/platform_interface_firebase_remote_config.dart @@ -197,6 +197,9 @@ abstract class FirebaseRemoteConfigPlatform extends PlatformInterface { } /// Get a [Stream] of [RemoteConfigUpdate]s. + /// + /// Real-time connection errors are added to the stream as [FirebaseException]s + /// with a `config-update-*` or `unknown` code, and do not close it. Stream get onConfigUpdated { throw UnimplementedError('onConfigUpdated getter not implemented'); } diff --git a/packages/firebase_remote_config/firebase_remote_config_platform_interface/test/method_channel/method_channel_firebase_remote_config_test.dart b/packages/firebase_remote_config/firebase_remote_config_platform_interface/test/method_channel/method_channel_firebase_remote_config_test.dart index 74e0e4f2eeb9..06e3b12aa2db 100644 --- a/packages/firebase_remote_config/firebase_remote_config_platform_interface/test/method_channel/method_channel_firebase_remote_config_test.dart +++ b/packages/firebase_remote_config/firebase_remote_config_platform_interface/test/method_channel/method_channel_firebase_remote_config_test.dart @@ -2,11 +2,17 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_core_platform_interface/test.dart'; import 'package:firebase_remote_config_platform_interface/firebase_remote_config_platform_interface.dart'; import 'package:firebase_remote_config_platform_interface/src/method_channel/method_channel_firebase_remote_config.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setupFirebaseCoreMocks(); + test('parses native throttled fetch status', () { final remoteConfig = MethodChannelFirebaseRemoteConfig.instance.setInitialValues( @@ -17,4 +23,124 @@ void main() { expect(remoteConfig.lastFetchStatus, RemoteConfigFetchStatus.throttle); }); + + group('onConfigUpdated', () { + const EventChannel channel = + EventChannel('plugins.flutter.io/firebase_remote_config_updated'); + + late FirebaseApp app; + + setUpAll(() async { + app = await Firebase.initializeApp(); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockStreamHandler(channel, null); + }); + + /// Replays [send] to the stream, the way the Android and iOS plugins + /// report realtime events. + void mockNativeEvents(void Function(MockStreamHandlerEventSink) send) { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockStreamHandler( + channel, + MockStreamHandler.inline( + onListen: (Object? arguments, MockStreamHandlerEventSink events) => + send(events), + ), + ); + } + + Matcher isRealtimeError(String code, String message) { + return isA() + .having((e) => e.plugin, 'plugin', 'firebase_remote_config') + .having((e) => e.code, 'code', code) + .having((e) => e.message, 'message', message); + } + + test('reports realtime errors as FirebaseExceptions with their code', + () async { + const List codes = [ + 'config-update-stream-error', + 'config-update-message-invalid', + 'config-update-not-fetched', + 'config-update-unavailable', + 'unknown', + ]; + mockNativeEvents((events) { + for (final String code in codes) { + events.error( + code: 'firebase_remote_config', + message: 'native message', + details: {'code': code, 'message': 'msg $code'}, + ); + } + }); + + final remoteConfig = MethodChannelFirebaseRemoteConfig(app: app); + + await expectLater( + remoteConfig.onConfigUpdated, + emitsInOrder([ + for (final String code in codes) + emitsError(isRealtimeError(code, 'msg $code')), + ]), + ); + }); + + test('keeps the stream open after a realtime error', () async { + mockNativeEvents((events) { + events.error( + code: 'firebase_remote_config', + message: 'Unable to connect to the server. Try again in a few ' + 'minutes. HTTP status code: 503', + details: { + 'code': 'config-update-stream-error', + 'message': 'Unable to connect to the server. Try again in a few ' + 'minutes. HTTP status code: 503', + }, + ); + events.success(['welcome_message']); + }); + + final remoteConfig = MethodChannelFirebaseRemoteConfig(app: app); + + await expectLater( + remoteConfig.onConfigUpdated, + emitsInOrder([ + emitsError( + isRealtimeError( + 'config-update-stream-error', + 'Unable to connect to the server. Try again in a few minutes. ' + 'HTTP status code: 503', + ), + ), + isA().having( + (update) => update.updatedKeys, + 'updatedKeys', + {'welcome_message'}, + ), + ]), + ); + }); + + test('reports a realtime error without details as unknown', () async { + mockNativeEvents((events) { + events.error( + code: 'firebase_remote_config', + message: 'The server is temporarily unavailable.', + ); + }); + + final remoteConfig = MethodChannelFirebaseRemoteConfig(app: app); + + await expectLater( + remoteConfig.onConfigUpdated, + emitsError( + isRealtimeError('unknown', 'The server is temporarily unavailable.'), + ), + ); + }); + }); } diff --git a/packages/firebase_remote_config/firebase_remote_config_web/lib/firebase_remote_config_web.dart b/packages/firebase_remote_config/firebase_remote_config_web/lib/firebase_remote_config_web.dart index acd1da819b82..f4ac6e63d303 100644 --- a/packages/firebase_remote_config/firebase_remote_config_web/lib/firebase_remote_config_web.dart +++ b/packages/firebase_remote_config/firebase_remote_config_web/lib/firebase_remote_config_web.dart @@ -190,8 +190,10 @@ class FirebaseRemoteConfigWeb extends FirebaseRemoteConfigPlatform { @override Stream get onConfigUpdated { - return _delegate.onConfigUpdated - .map((event) => RemoteConfigUpdate(event.updatedKeys)); + return convertWebExceptions( + () => _delegate.onConfigUpdated + .map((event) => RemoteConfigUpdate(event.updatedKeys)), + ); } @override diff --git a/packages/firebase_remote_config/firebase_remote_config_web/lib/src/internals.dart b/packages/firebase_remote_config/firebase_remote_config_web/lib/src/internals.dart index 9fe83ef6a36c..0aa2a1a6cbff 100644 --- a/packages/firebase_remote_config/firebase_remote_config_web/lib/src/internals.dart +++ b/packages/firebase_remote_config/firebase_remote_config_web/lib/src/internals.dart @@ -6,12 +6,23 @@ import 'package:firebase_core/firebase_core.dart'; import 'package:_flutterfire_internals/_flutterfire_internals.dart' as internals; +/// The codes the JS SDK reports realtime update errors with, mapped to the +/// codes Android and iOS report the same errors with. +const Map _configUpdateErrorCodes = { + 'remoteconfig/stream-error': 'config-update-stream-error', + 'remoteconfig/update-message-invalid': 'config-update-message-invalid', + 'remoteconfig/update-not-fetched': 'config-update-not-fetched', + 'remoteconfig/realtime-unavailable': 'config-update-unavailable', +}; + /// Will return a [FirebaseException] from a thrown web error. /// Any other errors will be propagated as normal. R convertWebExceptions(R Function() cb) { return internals.guardWebExceptions( cb, plugin: 'firebase_remote_config', - codeParser: (code) => code.replaceFirst('remote_config/', ''), + codeParser: (code) => + _configUpdateErrorCodes[code] ?? + code.replaceFirst('remote_config/', ''), ); } diff --git a/packages/firebase_remote_config/firebase_remote_config_web/test/internals_test.dart b/packages/firebase_remote_config/firebase_remote_config_web/test/internals_test.dart new file mode 100644 index 000000000000..2017d1006c47 --- /dev/null +++ b/packages/firebase_remote_config/firebase_remote_config_web/test/internals_test.dart @@ -0,0 +1,65 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. +@TestOn('chrome') +library; + +import 'dart:async'; +import 'dart:js_interop'; +import 'dart:js_interop_unsafe'; + +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_remote_config_web/src/internals.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// An error shaped like the ones the JS SDK reports through `onConfigUpdate`. +JSObject firebaseError(String code, String message) { + final error = (globalContext['Error']! as JSFunction) + .callAsConstructor('Remote Config: $message ($code).'.toJS); + error['name'] = 'FirebaseError'.toJS; + error['code'] = code.toJS; + return error; +} + +void main() { + group('convertWebExceptions', () { + test('reports realtime errors with the codes Android and iOS use', + () async { + const Map codes = { + 'remoteconfig/stream-error': 'config-update-stream-error', + 'remoteconfig/update-message-invalid': 'config-update-message-invalid', + 'remoteconfig/update-not-fetched': 'config-update-not-fetched', + 'remoteconfig/realtime-unavailable': 'config-update-unavailable', + }; + final controller = StreamController.broadcast(sync: true); + final stream = convertWebExceptions(() => controller.stream); + + final expectation = expectLater( + stream, + emitsInOrder([ + for (final MapEntry(key: jsCode, value: code) in codes.entries) + emitsError( + isA() + .having((e) => e.plugin, 'plugin', 'firebase_remote_config') + .having((e) => e.code, 'code', code) + .having( + (e) => e.message, + 'message', + allOf( + contains('Unable to connect'), isNot(contains(jsCode))), + ), + ), + // The stream stays open after an error. + 'update', + ]), + ); + for (final String jsCode in codes.keys) { + controller.addError(firebaseError(jsCode, 'Unable to connect')); + } + controller.add('update'); + + await expectation; + await controller.close(); + }); + }); +} From a836e4bbcf4ea7c48e61f89b1a0c7819e3d2ae57 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Wed, 21 Oct 2026 15:58:27 +0200 Subject: [PATCH 3/3] style(remote_config): add a required trailing comma in the web test --- .../firebase_remote_config_web/test/internals_test.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/firebase_remote_config/firebase_remote_config_web/test/internals_test.dart b/packages/firebase_remote_config/firebase_remote_config_web/test/internals_test.dart index 2017d1006c47..a79e3dd813dc 100644 --- a/packages/firebase_remote_config/firebase_remote_config_web/test/internals_test.dart +++ b/packages/firebase_remote_config/firebase_remote_config_web/test/internals_test.dart @@ -46,7 +46,9 @@ void main() { (e) => e.message, 'message', allOf( - contains('Unable to connect'), isNot(contains(jsCode))), + contains('Unable to connect'), + isNot(contains(jsCode)), + ), ), ), // The stream stays open after an error.