Why does an Android team need feature flags?
Architecture: The Feature Flag Layer
// The interface the rest of the app depends on
interface FeatureFlags {
fun isEnabled(flag: Flag): Boolean
}
// A closed set: Flag.entries lists every flag, no reflection needed
enum class Flag(
val key: String,
val defaultValue: Boolean,
val owner: String,
val removeBy: String? // ISO date, or null for a permanent switch
) {
NEW_CHECKOUT_FLOW(
key = "new_checkout_flow",
defaultValue = false,
owner = "payments-team",
removeBy = "2027-01-15"
),
AI_RECOMMENDATIONS(
key = "ai_recommendations",
defaultValue = false,
owner = "ml-team",
removeBy = "2027-02-01"
),
PAYMENTS_KILL_SWITCH(
key = "payments_enabled",
defaultValue = true,
owner = "payments-team",
removeBy = null
),
}
// A fake for tests
class FakeFeatureFlags(
private val enabled: Set<Flag> = emptySet()
) : FeatureFlags {
override fun isEnabled(flag: Flag) = flag in enabled
}Firebase Remote Config Implementation
ConfigUpdateListener has two methods, onUpdate and onError (reference). It cannot be written as a single lambda.
- The listener is not removed for you. addOnConfigUpdateListener returns a registration, and remove() is documented as "Removes the listener being tracked by this ConfigUpdateListenerRegistration" (reference). The old sample left a comment claiming it was automatic.class FirebaseFeatureFlags(
private val remoteConfig: FirebaseRemoteConfig
) : FeatureFlags {
// Call once at startup, before the first screen reads a flag.
fun start() {
remoteConfig.setDefaultsAsync(
Flag.entries.associate { it.key to it.defaultValue }
)
// Strategy 3: use what the last session fetched...
remoteConfig.activate()
// ...and fetch for the next one. Nothing changes mid-session.
remoteConfig.fetch()
}
override fun isEnabled(flag: Flag): Boolean =
remoteConfig.getBoolean(flag.key)
// Real-time, for the few flags that cannot wait for a restart.
fun killSwitch(flag: Flag): Flow<Boolean> = callbackFlow {
trySend(isEnabled(flag))
val listener = object : ConfigUpdateListener {
override fun onUpdate(configUpdate: ConfigUpdate) {
if (flag.key in configUpdate.updatedKeys) {
remoteConfig.activate().addOnCompleteListener {
trySend(isEnabled(flag))
}
}
}
override fun onError(error: FirebaseRemoteConfigException) {
close(error)
}
}
val registration = remoteConfig.addOnConfigUpdateListener(listener)
awaitClose { registration.remove() }
}
}Using Feature Flags in Compose UI
@HiltViewModel
class CheckoutViewModel @Inject constructor(
featureFlags: FeatureFlags
) : ViewModel() {
// Read once: stable for the life of this screen
val useNewFlow: Boolean =
featureFlags.isEnabled(Flag.NEW_CHECKOUT_FLOW)
}
@Composable
fun CheckoutScreen(viewModel: CheckoutViewModel = hiltViewModel()) {
CheckoutContent(
useNewFlow = viewModel.useNewFlow,
onComplete = viewModel::onCheckoutComplete
)
}
// The stateless composable takes the value, not the flag system
@Composable
fun CheckoutContent(useNewFlow: Boolean, onComplete: () -> Unit) {
if (useNewFlow) {
NewCheckoutFlow(onComplete = onComplete)
} else {
LegacyCheckoutFlow(onComplete = onComplete)
}
}Debug Overrides and QA Tools
runBlocking inside isEnabled, which blocks the calling thread on disk I/O every time a flag is read, including on the main thread. Keep the overrides in memory, load them once, and write them through to storage in the background. A DataStore is a reasonable place to persist them.// Debug builds only: local overrides win over the real provider
class DebugFeatureFlags(
private val real: FeatureFlags,
initialOverrides: Map<Flag, Boolean> = emptyMap()
) : FeatureFlags {
private val overrides = MutableStateFlow(initialOverrides)
override fun isEnabled(flag: Flag): Boolean =
overrides.value[flag] ?: real.isEnabled(flag)
fun setOverride(flag: Flag, value: Boolean) {
overrides.update { it + (flag to value) }
}
fun clearOverride(flag: Flag) {
overrides.update { it - flag }
}
fun clearAll() {
overrides.value = emptyMap()
}
// Collect this to persist changes off the main thread
val changes: StateFlow<Map<Flag, Boolean>> = overrides.asStateFlow()
}Flag Hygiene: Expiration and Cleanup
Flag.entries and compares removeBy with today's date is enough. The failure message names the owner.
3. Removal is real work. Delete the definition, delete the losing branch, delete the tests for it, and remove the parameter from the console. Plan it like any other change.
4. Test both sides while the flag lives. A flag doubles the paths through the code it guards. Run the tests for that code with the flag on and with it off.
Run the overdue check in the same CI pipeline as everything else, so that an expired flag is a red build and a decision, not a surprise a year later.class FlagHygieneTest {
@Test
fun `no temporary flag is past its removal date`() {
val today = LocalDate.now()
val overdue = Flag.entries.filter { flag ->
flag.removeBy?.let { LocalDate.parse(it) < today } ?: false
}
assertTrue(
"Overdue flags: " +
overdue.joinToString { it.key + " (" + it.owner + ")" },
overdue.isEmpty()
)
}
}Key Takeaways
- 1A flag separates shipping code from switching it on. A Play staged rollout controls who gets a build; a flag controls what the build does.
- 2Hide the provider behind one interface so screens, tests and QA overrides never touch the Firebase SDK.
- 3Firebase advises against activating new values where the UI could change noticeably mid-use. Activate structural flags on the next startup.
- 4ConfigUpdateListener has onUpdate and onError, and the listener must be removed through its registration.
- 5Read a structural flag once in the ViewModel and pass a plain value to the composable.
- 6Give every temporary flag an owner and a removal date, and fail a test when the date passes.
Frequently Asked
Should I use feature flags for everything?
No. Use them where being able to switch something off without a release is worth an extra code path: risky features, staged exposure and kill switches for things that depend on a backend. Every flag doubles the paths through the code it guards, so a stable feature is better shipped with no flag at all.
When should a Remote Config value take effect?
For anything that changes the structure of a screen, on the next app start. Firebase's loading guide warns against activating values where the UI could change noticeably while someone is using it. Activate the previously fetched values at startup and fetch new ones for the following session. Reserve real-time updates for kill switches.
How do I test feature flags?
Depend on an interface and pass a fake in unit tests, so no test needs the network or the Firebase SDK. Run the tests for flagged code with the flag on and off. In debug builds, wrap the provider with local overrides so testers can force either state without changing the production configuration.
Ready to architect your next Android app?
ANDROID-ARCHITECT generates production-ready Kotlin code, architecture blueprints, and CI/CD configurations from plain-language descriptions. Start building for free.