-
Notifications
You must be signed in to change notification settings - Fork 0
Multi Module Setup
In a multi-module project two plugin IDs cooperate:
-
dev.androidbroadcast.featured— the producer plugin. Apply to every module that declares flags infeatured { }. Each module publishes afeatured-manifest.jsonartifact alongside its compiled outputs. -
dev.androidbroadcast.featured.application— the aggregator plugin. Apply once, in the module that owns the singleConfigValuesinstance (typically the app or a shared KMP module). Readsfeatured-manifest.jsonfrom eachfeaturedAggregation(...)dependency and generatesGeneratedFeaturedRegistry.
// :feature:checkout/build.gradle.kts
plugins {
id("dev.androidbroadcast.featured")
}
featured {
localFlags {
boolean("new_checkout", default = false) {
description = "Enable the redesigned checkout flow"
category = "checkout"
}
enum(
key = "checkout_variant",
typeFqn = "com.example.CheckoutVariant",
default = "LEGACY",
) {
description = "Controls which checkout variant is shown"
category = "checkout"
}
}
remoteFlags {
boolean("promo_banner_enabled", default = false) {
description = "Show the promotional banner"
category = "promotions"
}
}
}This generates per-module objects such as GeneratedLocalFlagsFeatureCheckout and GeneratedRemoteFlagsFeatureCheckout. Each object name includes a module-derived suffix to avoid JVM class-name collisions when multiple feature modules land on the same classpath.
Rather than having consumers import the generated objects directly, each feature module ships thin public extension functions on ConfigValues — the observe-bridge:
// :feature:checkout/src/commonMain/.../CheckoutFlagObservers.kt
public fun ConfigValues.newCheckoutFlow(): Flow<Boolean> =
observe(GeneratedLocalFlagsFeatureCheckout.newCheckout).map { it.value }
public suspend fun ConfigValues.setNewCheckout(value: Boolean) {
override(GeneratedLocalFlagsFeatureCheckout.newCheckout, value)
}Consumers call configValues.newCheckoutFlow() — the generated class name stays an implementation detail. See Sample App for the full working example.
Apply dev.androidbroadcast.featured.application in the module that owns ConfigValues and wire the generated source directory:
// :app/build.gradle.kts (or :sample:shared for a KMP shared module)
plugins {
id("dev.androidbroadcast.featured.application")
}
dependencies {
// Primitive-only modules: featuredAggregation is enough.
featuredAggregation(project(":feature-promotions"))
// Enum-flag modules: also a regular api/implementation dep so the enum
// class is on the compile classpath at the aggregator level.
featuredAggregation(project(":feature-checkout"))
api(project(":feature-checkout"))
}
// Wire the generated source set (KMP style):
sourceSets.commonMain.get().kotlin.srcDir(
tasks.named("generateFeaturedRegistry").map { it.outputs.files.singleFile.parentFile },
)
// Non-KMP / single-platform style:
// kotlin.sourceSets.commonMain.kotlin.srcDir(
// layout.buildDirectory.dir("generated/featured/commonMain"),
// )The plugin generates:
object GeneratedFeaturedRegistry {
val all: List<ConfigParam<*>> = listOf(/* all aggregated flags */)
}Pass GeneratedFeaturedRegistry.all wherever a flag list is needed — typically the debug UI:
FeatureFlagsDebugScreen(
configValues = configValues,
registry = GeneratedFeaturedRegistry.all,
)The plugin registers a resolveFeatureFlags task per module. To resolve flags across every module that applies the plugin, use Gradle's built-in name-matched task invocation — no project path, no root aggregator task:
# Resolve flags across all modules (runs resolveFeatureFlags in every module that applies the plugin)
./gradlew resolveFeatureFlags
# Generate R8 rules for all Android modules
./gradlew generateFeaturedProguardRules
# Generate xcconfig across all modules
./gradlew generateXcconfigSince v1.2.0 the plugin no longer registers a
scanAllLocalFlagsroot task. The name-matched./gradlew resolveFeatureFlagsinvocation is the equivalent, and it keeps the plugin Project-Isolation-safe (norootProjectaccess).
Feature modules declare their own ConfigParam objects but do not create ConfigValues. A single ConfigValues instance lives in the app module and is injected into feature modules through dependency injection:
// :app/src/main/kotlin/AppModule.kt (example with manual DI)
val configValues = ConfigValues(
localProvider = DataStoreConfigValueProvider(dataStore),
)
// Inject into :feature:checkout
val checkoutViewModel = CheckoutViewModel(configValues)Feature modules read flags through the generated extension functions on ConfigValues:
// In :feature:checkout
class CheckoutViewModel(private val configValues: ConfigValues) : ViewModel() {
val isNewFlowEnabled: StateFlow<Boolean> =
configValues.observe(GeneratedLocalFlags.checkoutNewFlow)
.map { it.value }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
}When multiple feature modules sit on the same JVM classpath (e.g. all in :sample:shared via api(project(...))), plain object GeneratedLocalFlags in each module would produce a duplicate-class error at link time. The generator appends a suffix derived from the Gradle project path — GeneratedLocalFlagsSampleFeatureCheckout, GeneratedLocalFlagsSampleFeatureUi, etc. — making each name unique. @file:JvmName is therefore not needed and is not emitted.
wireProguardToVariants (which injects per-flag R8 rules into Android build variants) operates via the AGP Variant API and applies only to com.android.library and com.android.application targets. It is a no-op on com.android.kotlin.multiplatform.library targets — those modules must configure ProGuard rules manually if needed.
As of v1.2.0 the plugin is both Configuration Cache safe and Project-Isolation-safe. The scanAllLocalFlags root task and its wireToRootAggregator() wiring (the only rootProject access) were removed — flag resolution across all modules now uses the built-in ./gradlew resolveFeatureFlags invocation. Cross-module registry aggregation goes through the declarative featuredAggregation configuration, which was already isolation-safe. See Known Limitations for history.