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/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/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..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 @@ -28,14 +28,26 @@ 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 : - 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) { @@ -185,20 +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( - 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) - } - }) + addConfigUpdateListener(appName, createConfigUpdateListener(events, mainThreadExecutor)) } override fun onCancel(arguments: Any?) { @@ -256,6 +257,43 @@ 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) { + val details = + mapOf("code" to configUpdateErrorCode(error.code), "message" to error.message) + mainThreadExecutor.execute { + 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 new file mode 100644 index 000000000000..b2f0081d9835 --- /dev/null +++ b/packages/firebase_remote_config/firebase_remote_config/android/src/test/kotlin/io/flutter/plugins/firebase/firebaseremoteconfig/FirebaseRemoteConfigPluginTest.kt @@ -0,0 +1,166 @@ +// 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.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 +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 + +@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 $errorDetails") + } + + override fun endOfStream() { + ensureRunningOnMainThread() + received.add("endOfStream") + } + } + + private val events = MainThreadOnlyEventSink() + + @After + fun tearDown() { + mainThreadExecutor.shutdownNow() + } + + private fun nextEvent(): String? = events.received.poll(5, TimeUnit.SECONDS) + + @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. " + + "{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 + fun onUpdate_isForwardedToDartOnTheMainThread() { + val listener = FirebaseRemoteConfigPlugin.createConfigUpdateListener(events, mainThreadExecutor) + + listener.onUpdate(ConfigUpdate.create(setOf("welcome_message"))) + + 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 59856a16fa83..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,7 +207,15 @@ 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)") + let nsError = error as NSError + DispatchQueue.main.async { + events( + FlutterError( + code: "firebase_remote_config", + message: nsError.localizedDescription, + details: FLTFirebaseRemoteConfigUtils.configUpdateErrorCodeAndMessage(from: nsError) + )) + } return } if let update { 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..a79e3dd813dc --- /dev/null +++ b/packages/firebase_remote_config/firebase_remote_config_web/test/internals_test.dart @@ -0,0 +1,67 @@ +// 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(); + }); + }); +}