镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/android_unit_tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions docs/remote-config/_get-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, ConfigUpdateListenerRegistration> = HashMap()
private var eventChannel: EventChannel? = null
private val mainThreadHandler = Handler(Looper.getMainLooper())
private var messenger: BinaryMessenger? = null

override fun onAttachedToEngine(binding: FlutterPluginBinding) {
Expand Down Expand Up @@ -185,20 +197,9 @@ class FirebaseRemoteConfigPlugin :
override fun onListen(arguments: Any, events: EventSink) {
val argumentsMap = arguments as Map<String, Any>
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?) {
Expand Down Expand Up @@ -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>) -> Unit) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String>()

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<Pair<String, ConfigUpdateListener>>()
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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -110,15 +110,21 @@ class _HomePageState extends State<HomePage> {
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...';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
}
}
Loading
Loading