Skip to content
Merged
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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `FeatureFlagsDebugScreen` signature is now `(configValues: ConfigValues, registry: List<ConfigParam<*>>, modifier: Modifier = Modifier)` — accepts an explicit registry list instead of reading the (removed) `FlagRegistry` singleton. Pass `GeneratedFeaturedRegistry.all` for the recommended aggregator-plugin flow, or build the list inline for small projects.
- `:sample:shared` is now a pure aggregator: it applies `dev.androidbroadcast.featured.application`, declares `featuredAggregation(project(":sample:feature-*"))`, and consumes `GeneratedFeaturedRegistry.all`. The hand-written `SampleFeatureFlags.kt` is removed.
- Generator file names include a module-derived suffix (`GeneratedLocalFlagsSampleFeatureCheckout.kt`, etc.) — eliminates JVM class-name collisions when multiple modules share the same classpath. `@file:JvmName` is no longer emitted.
- `ExtensionFunctionGenerator` emits non-suspend `is*Enabled()` / `get*()` extension functions — they delegate to `getValueCached` and can be called from any context without a coroutine. Callers that previously wrapped them in `runBlocking { … }` or a coroutine scope can drop the wrapper. `GeneratedLocalFlags*` / `GeneratedRemoteFlags*` objects are widened to `public` so observer bridges can reference them across module boundaries.
- `ExtensionFunctionGenerator` emits non-suspend `is*Enabled()` / `get*()` extension functions — they delegate to `getValueCached` and can be called from any context without a coroutine. Callers that previously wrapped them in `runBlocking { … }` or a coroutine scope can drop the wrapper.
- `ConfigValues.resetOverride` re-resolves the effective value synchronously through the full provider priority chain; [getValueCached] reflects the updated value immediately after the call returns.
- Generated `GeneratedLocalFlagsX` / `GeneratedRemoteFlagsX` objects are now `internal` to their declaring Gradle module — each feature module's flag declarations are an implementation detail and no longer leak across module boundaries. Cross-module flag introspection (e.g. the debug screen) flows exclusively through `GeneratedFeaturedRegistry.all`, which the aggregator plugin builds from per-module manifests. The sample app demonstrates the per-module wiring pattern: one `ConfigValues` per feature module plus a dedicated debug aggregator, all sharing the same `LocalConfigValueProvider`.

### Added

Expand Down
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,35 @@ val configValues = ConfigValues(
val isEnabled: Boolean = configValues.isNewCheckoutEnabled()
```

## Multi-module pattern

In a multi-module app, construct one `ConfigValues` per feature module plus one debug aggregator,
all sharing the same `LocalConfigValueProvider`:

```kotlin
// Construct one ConfigValues per feature module + one debug aggregator, all over a shared provider
val sharedLocal: LocalConfigValueProvider = defaultLocalProvider(applicationContext)

val checkoutConfig = ConfigValues(localProvider = sharedLocal)
val promotionsConfig = ConfigValues(localProvider = sharedLocal)
val uiConfig = ConfigValues(localProvider = sharedLocal)

// Debug-only aggregator that the FeatureFlagsDebugScreen drives
val debugConfig = ConfigValues(localProvider = sharedLocal)

FeatureFlagsDebugScreen(
configValues = debugConfig,
registry = GeneratedFeaturedRegistry.all,
)
```

Each feature module owns its own `ConfigValues` and observes only its own flags (via public
observe-bridge extensions). The generated `GeneratedLocalFlagsX` / `GeneratedRemoteFlagsX` objects
are `internal` to their module — cross-module flag listing flows exclusively through
`GeneratedFeaturedRegistry.all`, which is built from the per-module manifests by the aggregator
plugin. The single source of truth for stored overrides is the shared `LocalConfigValueProvider`,
so writes from any instance propagate to every other one through its reactive `observe` flow.

## Documentation

Full documentation lives in the [Wiki](https://github.com/AndroidBroadcast/Featured/wiki):
Expand Down
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
package dev.androidbroadcast.featured.gradle

/**
* Generates `GeneratedLocalFlags<Suffix>.kt` and `GeneratedRemoteFlags<Suffix>.kt` — public
* Generates `GeneratedLocalFlags<Suffix>.kt` and `GeneratedRemoteFlags<Suffix>.kt` — internal
* objects containing one typed `ConfigParam` property per declared flag.
*
* Generated example for a local Boolean flag `dark_mode` in module `:sample:feature-checkout`:
* ```kotlin
* public object GeneratedLocalFlagsSampleFeatureCheckout {
* public val darkMode = ConfigParam<Boolean>("dark_mode", false, category = "UI")
* internal object GeneratedLocalFlagsSampleFeatureCheckout {
* val darkMode = ConfigParam<Boolean>("dark_mode", false, category = "UI")
* }
* ```
*
* The object name and file name include a module-derived suffix (e.g. `SampleFeatureCheckout`)
* so that each module's generated class has a unique JVM name, avoiding duplicate-class errors
* when multiple modules are assembled into the same DEX or JAR.
*
* These objects are `public` so that consumers in other modules (e.g. observe-bridge
* composites, feature-module bridges) can reference the `ConfigParam` instances directly
* via `observe(GeneratedLocalFlagsSampleFeatureCheckout.x)` without going through the
* generated extension functions in [ExtensionFunctionGenerator].
* These objects are `internal` to their declaring Gradle module — a module's flag declarations
* are an implementation detail that other modules must not reference directly. Cross-module
* flag introspection goes exclusively through [GeneratedFeaturedRegistry.all], which constructs
* `ConfigParam` instances inline from manifest data without referencing these objects.
*/
public object ConfigParamGenerator {
private const val PACKAGE = "dev.androidbroadcast.featured.generated"
Expand Down Expand Up @@ -88,9 +88,9 @@ public object ConfigParamGenerator {
appendLine()
appendLine("import $CONFIG_PARAM_IMPORT")
appendLine()
appendLine("public object $objectName {")
appendLine("internal object $objectName {")
entries.forEach { entry ->
appendLine(" public val ${entry.propertyName} = ${entry.toConfigParamExpression()}")
appendLine(" val ${entry.propertyName} = ${entry.toConfigParamExpression()}")
}
append("}")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ package dev.androidbroadcast.featured.gradle
*
* Extensions are `internal` because no external production consumer depends on them — modules
* that need `ConfigParam` values directly use `observe(GeneratedLocalFlags.x)` against the
* now-`public` generated objects.
* `internal` generated objects within the same Gradle module.
*
* **JVM class-name uniqueness:** `@file:JvmName` is intentionally absent — it is not
* supported on Kotlin/Native targets. Instead, the emitted file is named
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,17 @@ class ConfigParamGeneratorTest {
}

@Test
fun `local object is public`() {
fun `local object is internal`() {
val entries = listOf(localEntry("dark_mode", "false", "Boolean"))
val (local, _) = ConfigParamGenerator.generate(entries, modulePath)
assertContains(local, "public object GeneratedLocalFlagsApp")
assertContains(local, "internal object GeneratedLocalFlagsApp")
}

@Test
fun `local properties do not have explicit public modifier`() {
val entries = listOf(localEntry("dark_mode", "false", "Boolean"))
val (local, _) = ConfigParamGenerator.generate(entries, modulePath)
assertTrue(!local.contains("public val "), "Property declarations must not carry explicit 'public' modifier")
}

@Test
Expand Down Expand Up @@ -86,10 +93,17 @@ class ConfigParamGeneratorTest {
}

@Test
fun `remote object is public`() {
fun `remote object is internal`() {
val entries = listOf(remoteEntry("promo", "false", "Boolean"))
val (_, remote) = ConfigParamGenerator.generate(entries, modulePath)
assertContains(remote, "internal object GeneratedRemoteFlagsApp")
}

@Test
fun `remote properties do not have explicit public modifier`() {
val entries = listOf(remoteEntry("promo", "false", "Boolean"))
val (_, remote) = ConfigParamGenerator.generate(entries, modulePath)
assertContains(remote, "public object GeneratedRemoteFlagsApp")
assertTrue(!remote.contains("public val "), "Property declarations must not carry explicit 'public' modifier")
}

// ── empty cases ───────────────────────────────────────────────────────────
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,16 @@ import kotlin.concurrent.atomics.update
* }
* ```
*
* ### Multi-module wiring
*
* When wiring a multi-module application, construct one [ConfigValues] per feature module so each
* module sees only the flags it declares. All [ConfigValues] instances should share the same
* [LocalConfigValueProvider] (and [RemoteConfigValueProvider], if any) — the provider is the
* single source of truth for stored overrides, and its reactive [observe] flow propagates writes
* from any [ConfigValues] instance to every other one that shares the provider. A debug screen
* that exposes every flag across modules is just one extra [ConfigValues] built from the same
* shared providers and driven by `GeneratedFeaturedRegistry.all`.
*
* @param localProvider Optional provider for locally persisted overrides.
* @param remoteProvider Optional provider for remote configuration values.
* @param onProviderError Optional callback invoked whenever a provider throws during
Expand Down
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ androidx-test-runner = { module = "androidx.test:runner", version.ref = "android

androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
androidx-lifecycle-viewmodel = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel", version.ref = "androidx-lifecycle" }
androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" }
Expand Down
10 changes: 8 additions & 2 deletions sample/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ The sample is intentionally a multi-module demonstration of the Featured plugin
- `:sample:feature-checkout` — owns `CheckoutVariant` enum + 2 local flags (`new_checkout` Boolean, `checkout_variant` enum).
- `:sample:feature-promotions` — 1 remote flag (`promo_banner_enabled` Boolean).
- `:sample:feature-ui` — 2 local UI flags (`main_button_red` Boolean, `new_feature_section_enabled` Boolean).
- `:sample:shared` — pure aggregator (`dev.androidbroadcast.featured.application`). Contains Compose UI (`FeaturedSample`, `SampleApp`) and `SampleViewModel`. No flag declarations of its own.
- `:sample:shared` — pure aggregator (`dev.androidbroadcast.featured.application`). Contains Compose UI (`FeaturedSample`, `SampleApp`); per-feature ViewModels (`CheckoutFlagsViewModel`, `PromotionsFlagsViewModel`, `UiFlagsViewModel`) live in their respective `:sample:feature-*` modules. No flag declarations of its own.
- `:sample:android-app` — Activity shell; wires `DataStoreConfigValueProvider` + `FeatureFlagsDebugScreen`.
- `:sample:desktop` — JVM shell; uses `InMemoryConfigValueProvider`.
- `iosApp/` — Xcode project consuming `FeaturedSampleApp.framework` (static, produced by `:sample:shared`).
Expand All @@ -26,8 +26,14 @@ under the hood.

1. Edit the feature module's `build.gradle.kts` — add a declaration inside `featured { localFlags { ... } }` or `featured { remoteFlags { ... } }`.
2. Add a public observer / setter in `*FlagObservers.kt`.
3. If the UI needs it, expose a `StateFlow` + setter in `SampleViewModel`.
3. If the UI needs it, expose a `StateFlow` + setter in the feature's `*FlagsViewModel` (e.g. `CheckoutFlagsViewModel`).

## Aggregation

`:sample:shared` declares `featuredAggregation(project(":sample:feature-*"))` for all three modules and wires the `generateFeaturedRegistry` task output into `commonMain`. The resulting `GeneratedFeaturedRegistry.all` is passed to `FeatureFlagsDebugScreen`.

## Multi-module wiring

The sample constructs one `ConfigValues` per `:sample:feature-*` module — three per-feature instances on every platform. On Android the shell builds one additional `ConfigValues` (`debugConfigValues`) and passes it to `FeatureFlagsDebugScreen`; Desktop and iOS do not wire a debug-UI entry and omit that fourth instance. All instances share the same `LocalConfigValueProvider`, so overrides toggled through the debug screen propagate to every per-module instance via the shared provider's reactive `observe`. Each feature module's flag declarations are encapsulated behind its `internal` `GeneratedLocalFlagsX` object and exposed only via public observe-bridge extensions (`*FlagObservers.kt`) and a per-feature `ViewModel` that takes only its own `ConfigValues`.

This is the canonical demonstration of the recommended pattern for real apps: a 20-module app wires 20 production `ConfigValues` plus, where a debug surface exists, one extra `ConfigValues` for the debug screen — all over a single DataStore.
2 changes: 2 additions & 0 deletions sample/android-app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,6 @@ dependencies {
implementation(project(":providers:datastore"))
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.appcompat)
// viewModel { } composable used in setContent to scope VMs to the Activity ViewModelStore.
implementation(libs.androidx.lifecycle.viewmodelCompose)
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.lifecycle.viewmodel.compose.viewModel
import dev.androidbroadcast.featured.ConfigValues
import dev.androidbroadcast.featured.SampleApp
import dev.androidbroadcast.featured.datastore.DataStoreConfigValueProvider
Expand All @@ -17,33 +18,54 @@ import dev.androidbroadcast.featured.debugui.FeatureFlagsDebugScreen
import dev.androidbroadcast.featured.enumConverter
import dev.androidbroadcast.featured.generated.GeneratedFeaturedRegistry
import dev.androidbroadcast.featured.platform.defaultLocalProvider
import dev.androidbroadcast.featured.sample.checkout.CheckoutFlagsViewModel
import dev.androidbroadcast.featured.sample.checkout.CheckoutVariant
import dev.androidbroadcast.featured.sample.promotions.PromotionsFlagsViewModel
import dev.androidbroadcast.featured.sample.ui.UiFlagsViewModel

class MainActivity : ComponentActivity() {
// ConfigValues is held at Activity scope for this sample.
// In production, move to Application or a DI singleton to avoid
// recreating (and re-opening) the DataStore file on every rotation.
private val configValues by lazy {
val localProvider = defaultLocalProvider(applicationContext)
// A single LocalConfigValueProvider is shared across all ConfigValues instances so
// every module reads and writes the same underlying DataStore file. In production,
// move this to Application scope or a DI singleton to avoid reopening the file on rotation.
private val sharedLocalProvider by lazy {
val provider = defaultLocalProvider(applicationContext)
// DataStore only handles primitives natively; register a converter so that the
// enum-typed checkoutVariant flag can be persisted and observed without throwing.
(localProvider as? DataStoreConfigValueProvider)
(provider as? DataStoreConfigValueProvider)
?.registerConverter(enumConverter<CheckoutVariant>())
ConfigValues(localProvider = localProvider)
provider
}

// Each feature module gets its own ConfigValues instance backed by the same provider.
// Per-module ConfigValues is the pattern Featured is designed around: flags are scoped
// to the module that declared them.
private val checkoutConfigValues by lazy { ConfigValues(localProvider = sharedLocalProvider) }
private val promotionsConfigValues by lazy { ConfigValues(localProvider = sharedLocalProvider) }
private val uiConfigValues by lazy { ConfigValues(localProvider = sharedLocalProvider) }

// Aggregated ConfigValues for the debug screen — observes all flags across all modules.
private val debugConfigValues by lazy { ConfigValues(localProvider = sharedLocalProvider) }

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
// ViewModels are created here so they are scoped to the Activity's ViewModelStore
// and survive configuration changes. Each VM receives its module's ConfigValues.
val checkoutViewModel = viewModel(key = "checkout") { CheckoutFlagsViewModel(checkoutConfigValues) }
val promotionsViewModel = viewModel(key = "promotions") { PromotionsFlagsViewModel(promotionsConfigValues) }
val uiViewModel = viewModel(key = "ui") { UiFlagsViewModel(uiConfigValues) }

var showDebug by rememberSaveable { mutableStateOf(false) }

if (showDebug) {
BackHandler { showDebug = false }
FeatureFlagsDebugScreen(configValues = configValues, registry = GeneratedFeaturedRegistry.all)
FeatureFlagsDebugScreen(configValues = debugConfigValues, registry = GeneratedFeaturedRegistry.all)
} else {
SampleApp(
configValues = configValues,
uiViewModel = uiViewModel,
promotionsViewModel = promotionsViewModel,
checkoutViewModel = checkoutViewModel,
onOpenDebugUi = { showDebug = true },
)
}
Expand Down
Loading
Loading