Skip to content
Software Development

Automating Firebase Remote Config with KSP codegen in an Android app

Marek Venc
Marek Venc 9 min read

We noticed that our Android app wasn’t using Firebase Remote Config’s realtime updates. Instead, it was repeatedly fetching config values, and that polling was slowly creeping toward Firebase’s rate limits. Fixing it properly meant looking at how we handle remote config anyway, and the closer I looked, the clearer it became that the layer was neither safe nor reusable. The rate limit made me look at the code. Safety and reusability justified the rewrite.

This is the story of what I built and why: a code generator that turns a plain Kotlin interface into a fully validated, reactive, QA-friendly Remote Config layer.

What was actually wrong

Firebase Remote Config’s raw Android API looks innocent enough:

val enabled = remoteConfig.getBoolean("cool_new_feature")

In a large multi-module app, even something as simple as remote config is never simple:

  • Keys are magic strings. A typo compiles fine and silently returns the default. Nobody notices until the feature mysteriously never turns on.
  • There is no single source of truth. Keys live in the Firebase console, in an XML defaults file, and scattered across call sites. Over time, these three drift apart. Keys get removed from the console but stay in the code. Keys get added to the code but never get a default. Nothing warns you.
  • It is imperative, not reactive. Call sites poll getBoolean() whenever they feel like it. There’s no first-class stream that re-emits when Firebase pushes an update, which is exactly how we ended up polling in the first place.
  • Complex types are hand-rolled. JSON-encoded config values get deserialized ad hoc, each call site with its own error handling.

The JSON parsing problem

Remote Config only knows strings. A JSON value in the Firebase console is just a string to Firebase, which means broken JSON is perfectly valid as far as Firebase is concerned. Someone edits a JSON config in the console, misses a bracket, hits publish, and Firebase serves the malformed value to every device.

Now, you might think: fine, parsing fails, so we fall back to the bundled default. Except you can’t. The API only gives you the last fetched value. Once a fetched value exists, the in-app default is unreachable, and there is no “give me the default instead” option. Your options are a crash, an ad-hoc null, or whatever each call site improvised.

So any serious wrapper has to solve two problems: at runtime, fall back to the default when parsing fails, and at build time, guarantee that the default itself is parseable so the fallback always works.

Why not just use an existing library?

Third-party libraries each covered part of what we wanted, but none covered everything, and none solved the JSON parsing problem. KFirebaseRemoteConfig, for example, is a great library, but the surface it exposes is still getString(key) calls: weakly typed, non-reactive, with no compile-time validation.

We also considered a more low-tech route: extension functions, conventions, maybe some well-written instructions for AI-assisted development. But conventions don’t enforce anything. They shift the burden onto code review and discipline, which is exactly where the original problems came from.

They also can’t give you what turned out to be one of the most useful additions in practice: the generated debug screen, where developers and QA can see every config key the app actually uses, inspect the raw remote values, and override any of them locally.

Before, testing config-dependent scenarios meant changing values in the shared Firebase console, so testers were constantly stepping on each other. One person flips a flag to test scenario A, and someone else’s scenario B breaks mid-test. With local overrides, every tester runs their own scenarios in parallel on their own device, and the console stays untouched.

That’s not something a convention gives you. So codegen it was.

Solution: declare an interface, get everything else

The end result is a small library module built on two tools: KSP (Kotlin Symbol Processing), which analyzes annotated code at compile time, and KotlinPoet, Square’s library for generating Kotlin source files. Instead of piecing generated code together from strings, KotlinPoet builds it as a proper model of Kotlin types, functions and properties, which keeps the generator readable and maintainable.

As a developer, this is all you write:

@RemoteConfigSource
internal interface FeatureRemoteConfig {

    @RemoteConfigProperty("cool_new_feature")
    val coolNewFeatureEnabled: StateFlow<Boolean>

    @RemoteConfigProperty("feature_rules")
    val featureRules: StateFlow<FeatureRules?>
}

Plus one entry per key in the standard Firebase XML defaults file, the same file Firebase loads at runtime anyway:

<entry><key>cool_new_feature</key><value>false</value></entry>
<entry><key>feature_rules</key><value>{"rules":[]}</value></entry>

Everything else is generated at compile time: the implementation class, metadata for the debug panel, and the validation glue. Adding a new config value means one interface property and one XML entry. Quick and safe for developers.

The build fails before you can ship a broken config

The XML defaults file plays a double role. Firebase loads it at runtime as the defaults, and the KSP processor reads it at compile time as the ground truth for validation. Every declared key must exist in the XML, and every default value must be parseable to the declared type.

Typo in a key? Build fails. A Boolean default that isn’t exactly true or false? Build fails. Default JSON that isn’t valid JSON? Build fails. Duplicate keys in the XML? Build fails. A whole class of runtime issues simply became compile errors, with messages that tell you exactly which key is wrong and why.

This is also what fixes the JSON parsing problem. Because the default is proven parseable at build time, the runtime can always fall back to it. When Firebase serves broken JSON, the generated code logs the failure and the flow keeps emitting a value the app can handle. No crashes, and no improvised null handling at call sites.

For custom @Serializable types, there is one deliberate rule: they must be declared nullable. And it isn’t a convention you have to remember, because the processor enforces it. Declare a StateFlow<FeatureRules> instead of StateFlow<FeatureRules?>, and the build fails with an error explaining that the type must be nullable because deserialization can fail at runtime.

The build validates that the default is valid JSON, but if a remote value ever fails to match the expected schema at runtime, the property resolves to null instead of crashing. The cost is that every consumer of a custom type has to handle null, but that’s a failure mode the codebase already knows how to deal with, unlike a crash.

Reactive by construction, and goodbye rate limits

Every generated property is a StateFlow. It emits the current value immediately and re-emits whenever Firebase pushes an update or a local override changes. Consumers just collect. There’s no polling anywhere.

Under the hood, there’s exactly one Firebase realtime update listener for the entire app. It feeds a shared invalidation bus that all the derived flows hang off, so a single pushed update re-resolves every affected value, everywhere, in sync.

That’s also the fix for the original problem. The repeated fetching that started all this disappeared, and we got plenty of other upsides along the way. One live connection, many flows, no polling.

Overrides that cost release builds nothing

The debug override panel is generated from the same metadata the processor emits. It lists every source and every key with its type, default and current remote value, and lets you set a local override. Overrides follow the same parsing rules as the real config, so an override that doesn’t parse falls back gracefully instead of breaking the app.

The panel exists only in debug builds. The build type selects between two implementations of the override store: a DataStore-backed one for debug, and a no-op for release. The resolution engine and all generated code are identical across build types; release builds just carry an override store where overrides never exist. No release footprint, and no risk of debugging features causing issues in production.

The hard part: testing generated code

Building the generator itself was the easy part. Testing it properly was hard.

The feature doesn’t live in one place. It spans several stages of building and testing. The KSP processor runs at compile time on the JVM, before the app exists. The generated code runs inside the app. The defaults validation needs to run in unit tests. And each stage only has access to some of the parts: the processor can’t see the running app, unit tests can’t re-run the processor, and the runtime engine knows nothing about the XML file.

So each stage needed its own testing strategy:

  • Unit tests for the processor’s parsing, type resolution and code generation.
  • Tests for the runtime engine’s resolution and listener behavior, including an assertion that there really is only one Firebase listener, no matter how many flows exist.
  • A defaults validation test that runs against the actual generated output.

Lastly, the setup is self-maintaining. The processor emits a ServiceLoader manifest alongside the generated code, and a single test discovers every config source through it and validates all their defaults. Adding a new source anywhere in the app requires zero test edits: the new source is discovered and validated automatically. Tests that maintain themselves beat tests that need to be maintained.

What actually changed

The measurable stuff: adding a config value went from touching scattered call sites, hand-rolling parsing and hoping for the best to one interface property and one XML entry, with the compiler checking your work. The polling and its rate-limit risk are gone, replaced by a single realtime listener.

The less measurable stuff turned out to matter more. QA now has visibility: the override panel shows exactly which keys the app actually uses, live. When a flag isn’t implemented yet, or has been removed, they see it without asking a developer. And because overrides are local, multiple testers run different config scenarios in parallel without fighting over the shared Firebase console.

And the thing we appreciate most, months later: the safety isn’t a convention someone has to remember. It’s structural. You can’t add a config value with a mistyped key, a broken default or missing error handling. The build and the tests will stop you. The best guardrails are the ones nobody has to think about.

If you’re maintaining a large remote config setup with just raw Firebase, I’d encourage you to look into a solution like this. It makes the codebase easier to maintain, adds useful testing features and makes everything more testable.

Marek Venc
Marek Venc
Cookielab

A Cookielabber. Writes code, drinks coffee, ships software.

Facing a similar technical problem?

Let's talk about how our experience can help your product, your team or your infrastructure.

Discuss your project