Featured is a type-safe, reactive feature-flag and configuration management library for Kotlin Multiplatform (Android, iOS, JVM). Declare flags in shared Kotlin code, read them at runtime from local or remote providers, and let the Gradle plugin dead-code-eliminate disabled flags from your production binaries.
- Overview
- Installation
- Quick start
- Using flags at runtime
- Providers
- Debug UI
- Release build optimization
- iOS integration
- Multi-module setup
- API reference
Use cases
- Ship code guarded by a flag that is off by default; enable it via Firebase Remote Config when you are ready to roll out.
- Override individual flags during development or QA without touching a remote backend.
- Eliminate dead code from Release binaries: the Gradle plugin generates R8 rules (Android/JVM) and an xcconfig file (iOS) that let the respective compilers strip disabled flag code paths at build time.
Key types
| Type | Role |
|---|---|
ConfigParam<T> |
Declares a named, typed configuration key with a default value |
ConfigValue<T> |
Wraps a param's current value and its source (DEFAULT / LOCAL / REMOTE) |
ConfigValues |
Container that composes local and remote providers |
LocalConfigValueProvider |
Interface for writable, observable local storage |
RemoteConfigValueProvider |
Interface for fetch-based remote configuration |
Add the BOM to manage all module versions from a single place, then declare only the artifacts you need.
// settings.gradle.kts
dependencyResolutionManagement {
repositories {
mavenCentral()
google()
}
}// build.gradle.kts (root or app module)
plugins {
id("dev.androidbroadcast.featured") version "<version>"
}
dependencies {
implementation(platform("dev.androidbroadcast.featured:featured-bom:<version>"))
// Core runtime — always required
implementation("dev.androidbroadcast.featured:core")
// Optional modules — add only what you use
implementation("dev.androidbroadcast.featured:featured-compose") // Compose extensions
debugImplementation("dev.androidbroadcast.featured:featured-registry") // Flag registry for debug UI
debugImplementation("dev.androidbroadcast.featured:featured-debug-ui") // Debug screen
// Local persistence providers — pick one (or both)
implementation("dev.androidbroadcast.featured:datastore-provider")
implementation("dev.androidbroadcast.featured:sharedpreferences-provider")
// Remote provider
implementation("dev.androidbroadcast.featured:firebase-provider")
}The Gradle plugin ID is
dev.androidbroadcast.featured. It is also published to Maven Central under the artifactdev.androidbroadcast.featured:featured-gradle-plugin.
Add the package in Xcode (File › Add Package Dependencies) or in Package.swift:
.package(
url: "https://github.com/AndroidBroadcast/Featured",
from: "<version>"
)Then add FeaturedCore as a target dependency:
.target(
name: "MyApp",
dependencies: [
.product(name: "FeaturedCore", package: "Featured")
]
)Flags are plain ConfigParam properties. Annotate them with @LocalFlag so the Gradle plugin can scan them for code generation.
// shared/src/commonMain/kotlin/com/example/FeatureFlags.kt
import dev.androidbroadcast.featured.ConfigParam
import dev.androidbroadcast.featured.LocalFlag
object FeatureFlags {
@LocalFlag
val newCheckout = ConfigParam<Boolean>(
key = "new_checkout",
defaultValue = false,
description = "Enable the new checkout flow",
category = "Checkout",
)
@LocalFlag
val maxCartItems = ConfigParam<Int>(
key = "max_cart_items",
defaultValue = 10,
description = "Maximum items allowed in cart",
)
}Wire up providers once, typically in your dependency injection setup or Application.onCreate.
// Android
val configValues = ConfigValues(
localProvider = DataStoreConfigValueProvider(preferencesDataStore),
remoteProvider = FirebaseConfigValueProvider(),
)ConfigValues requires at least one provider. Both localProvider and remoteProvider are optional individually, but at least one must be non-null.
// Suspend function — call from a coroutine
val value: ConfigValue<Boolean> = configValues.getValue(FeatureFlags.newCheckout)
val isEnabled: Boolean = value.value // the actual value
val source: ConfigValue.Source = value.source // DEFAULT, LOCAL, or REMOTEval configValue: ConfigValue<Boolean> = configValues.getValue(FeatureFlags.newCheckout)
if (configValue.value) {
// feature is active
}// Emits immediately with the current value, then on every change
configValues.observe(FeatureFlags.newCheckout)
.collect { configValue ->
println("new_checkout = ${configValue.value} (source: ${configValue.source})")
}
// Convenience: emit only the raw value, not the ConfigValue wrapper
configValues.observeValue(FeatureFlags.newCheckout)
.collect { isEnabled: Boolean -> /* … */ }
// Convert to StateFlow
val isEnabled: StateFlow<Boolean> = configValues.asStateFlow(
param = FeatureFlags.newCheckout,
scope = viewModelScope,
)@Composable
fun CheckoutScreen(configValues: ConfigValues) {
val isEnabled: State<Boolean> = configValues.collectAsState(FeatureFlags.newCheckout)
if (isEnabled.value) {
NewCheckoutContent()
} else {
LegacyCheckoutContent()
}
}Use LocalConfigValues to provide a ConfigValues through the composition tree:
// In your root composable
CompositionLocalProvider(LocalConfigValues provides configValues) {
AppContent()
}
// Anywhere below
@Composable
fun SomeDeepComponent() {
val configValues = LocalConfigValues.current
val enabled by configValues.collectAsState(FeatureFlags.newCheckout)
// …
}The FeatureFlags Swift class wraps CoreConfigValues (the KMP-exported type). Define your flags as FeatureFlag values that reference the shared CoreConfigParam exported from Kotlin:
import FeaturedCore
// Map a Kotlin ConfigParam to a Swift FeatureFlag
let newCheckoutFlag = FeatureFlag<Bool>(
param: CoreFeatureFlagsCompanion().newCheckout,
defaultValue: false
)
let featureFlags = FeatureFlags(configValues)
// Async read
let isEnabled = try await featureFlags.value(of: newCheckoutFlag)
// AsyncStream — use in a Task or async for-await loop
for await value in featureFlags.stream(of: newCheckoutFlag) {
updateUI(value)
}
// Combine publisher
featureFlags.publisher(for: newCheckoutFlag)
.receive(on: DispatchQueue.main)
.sink { isEnabled in updateUI(isEnabled) }
.store(in: &cancellables)No setup required. Values are stored in memory and lost on process restart. Useful for tests and previews.
val configValues = ConfigValues(
localProvider = InMemoryConfigValueProvider(),
)Persists overrides to Jetpack DataStore Preferences.
// Declare once per file, outside any function or class
private val Context.featureFlagsDataStore: DataStore<Preferences>
by preferencesDataStore(name = "feature_flags")
val configValues = ConfigValues(
localProvider = DataStoreConfigValueProvider(context.featureFlagsDataStore),
)Android-only. Persists overrides to SharedPreferences.
val prefs = context.getSharedPreferences("feature_flags", Context.MODE_PRIVATE)
val configValues = ConfigValues(
localProvider = SharedPreferencesProviderConfig(prefs),
)Wraps Firebase Remote Config. Remote values override local values.
val configValues = ConfigValues(
localProvider = DataStoreConfigValueProvider(dataStore),
remoteProvider = FirebaseConfigValueProvider(),
)
// Fetch and activate — suspend function, call from a coroutine (e.g., on app start)
lifecycleScope.launch { configValues.fetch() }FirebaseConfigValueProvider uses FirebaseRemoteConfig.getInstance() by default. Pass a custom instance if you manage the Firebase lifecycle yourself:
FirebaseConfigValueProvider(remoteConfig = FirebaseRemoteConfig.getInstance())// Write a local override — survives remote fetches
configValues.override(FeatureFlags.newCheckout, true)
// Revert to the provider's stored or default value
configValues.resetOverride(FeatureFlags.newCheckout)featured-debug-ui provides a ready-made Compose screen that lists all registered flags with their current values and sources, and lets you toggle or override them at runtime.
Register each ConfigParam in the FlagRegistry so the debug screen can discover them:
import dev.androidbroadcast.featured.registry.FlagRegistry
// Call once on app start (e.g., in Application.onCreate or your DI module)
FlagRegistry.register(FeatureFlags.newCheckout)
FlagRegistry.register(FeatureFlags.maxCartItems)import dev.androidbroadcast.featured.debugui.FeatureFlagsDebugScreen
@Composable
fun DebugMenuScreen(configValues: ConfigValues) {
FeatureFlagsDebugScreen(configValues = configValues)
}Only include featured-debug-ui and featured-registry in debug builds (they are already declared that way in the installation section above):
The Gradle plugin generates ProGuard / R8 -assumevalues rules for every @LocalFlag-annotated ConfigParam<Boolean> with defaultValue = false. These rules instruct R8 to treat the flag as a constant false at shrink time, so all code guarded by if (flag.value) is removed from the release APK.
The task runs automatically when you build a release variant. To run it manually:
./gradlew :app:generateProguardRulesOutput: app/build/featured/proguard-featured.pro
No extra configuration is needed — the plugin wires the output into the R8 pipeline automatically.
See the iOS integration section below.
The Gradle plugin generates an xcconfig file that feeds Swift compilation conditions into Xcode. For every @LocalFlag-annotated ConfigParam<Boolean> with defaultValue = false, a DISABLE_<FLAG_KEY> condition is generated.
| Kotlin flag key | Generated condition |
|---|---|
new_checkout |
DISABLE_NEW_CHECKOUT |
experimentalUi |
DISABLE_EXPERIMENTAL_UI |
./gradlew :shared:generateXcconfigOutput: shared/build/featured/FeatureFlags.generated.xcconfig
Example content:
# Auto-generated by featured-gradle-plugin — do not edit
SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) DISABLE_NEW_CHECKOUT DISABLE_EXPERIMENTAL_UI
Copy or symlink the file to a stable path inside your Xcode project tree:
# Copy (re-run after each generateXcconfig invocation)
cp shared/build/featured/FeatureFlags.generated.xcconfig \
iosApp/Configuration/FeatureFlags.generated.xcconfig
# Symlink (resolved automatically)
ln -sf ../../shared/build/featured/FeatureFlags.generated.xcconfig \
iosApp/Configuration/FeatureFlags.generated.xcconfigAdd the generated file to .gitignore if you use the copy approach:
iosApp/Configuration/FeatureFlags.generated.xcconfig- Open your
.xcodeprojin Xcode. - Select the project in the Navigator → Info tab → Configurations.
- Expand the Release configuration.
- Set the configuration file for your app target to
FeatureFlags.generated.xcconfig.
Only assign the xcconfig to Release. Debug builds intentionally omit it so every feature remains reachable during development.
// Entry point for the new checkout feature
#if !DISABLE_NEW_CHECKOUT
NewCheckoutButton()
#endif
// Deep-link handler
#if !DISABLE_NEW_CHECKOUT
case .newCheckout: NewCheckoutCoordinator.start()
#endif
// AppDelegate / SceneDelegate
#if !DISABLE_NEW_CHECKOUT
setupNewCheckoutObservers()
#endifThe Swift compiler removes the entire guarded block from Release binaries — zero runtime overhead.
Add this script to your Xcode target's Build Phases (before Compile Sources). Set Based on dependency analysis to off:
cd "${SRCROOT}/.."
./gradlew :shared:generateXcconfig --quiet
cp shared/build/featured/FeatureFlags.generated.xcconfig \
iosApp/Configuration/FeatureFlags.generated.xcconfigIn a multi-module project, apply the Gradle plugin to every module that declares @LocalFlag annotations. The plugin registers a scanLocalFlags task per module and an aggregator task scanAllLocalFlags at the root.
// :feature:checkout module build.gradle.kts
plugins {
id("dev.androidbroadcast.featured")
// … other plugins
}// :feature:profile module build.gradle.kts
plugins {
id("dev.androidbroadcast.featured")
}Run code generation tasks across all modules at once:
# Scan flags in all modules
./gradlew scanAllLocalFlags
# Generate R8 rules for all Android modules
./gradlew generateProguardRules
# Generate xcconfig across all modules
./gradlew generateXcconfigDeclare a single shared ConfigValues in your app module and inject it into feature modules through dependency injection. Feature modules declare their own ConfigParam objects but do not create ConfigValues themselves.
The sample module is a Kotlin Multiplatform app (Android + iOS + Desktop) that demonstrates
all provider options available in Featured.
No extra configuration needed. The sample uses defaultLocalProvider(context) from
:featured-platform, which returns a DataStoreConfigValueProvider on Android. Flag overrides
written via the debug UI persist across app restarts.
./gradlew :sample:assembleDebugTo see how SharedPreferencesProviderConfig is wired up, look at buildConfigValues() in
SampleApplication.kt. Swap the commented-out localProvider assignment for the active one.
Firebase Remote Config requires a google-services.json file from the Firebase console.
- Create a Firebase project at console.firebase.google.com.
- Register the Android app with package name
dev.androidbroadcast.featured. - Download
google-services.jsonand place it atsample/google-services.json. - Build the sample with the
hasFirebaseflag:
./gradlew :sample:assembleDebug -PhasFirebase=trueThe build system detects sample/google-services.json automatically, so step 4 can also be
run without -PhasFirebase=true once the file is present.
- In
SampleApplication.kt, uncomment theFirebaseConfigValueProviderlines insidebuildConfigValues()and rebuild.
Note:
google-services.jsonis excluded from version control (.gitignore). Never commit credentials to the repository.
Full KDoc-generated API reference is published to GitHub Pages:
https://androidbroadcast.github.io/Featured/
Documentation is regenerated on every merge to main.