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
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,14 @@ 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` now emits `suspend` extension functions — `ConfigValues.getValue` has always been suspend; the generated callers now match. `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. `GeneratedLocalFlags*` / `GeneratedRemoteFlags*` objects are widened to `public` so observer bridges can reference them across module boundaries.
- `ConfigValues.resetOverride` re-resolves the effective value synchronously through the full provider priority chain; [getValueCached] reflects the updated value immediately after the call returns.

### Added

- `ConfigValues.getValueCached(param: ConfigParam<T>): ConfigValue<T>` — non-suspend synchronous reader. Returns the last-written `ConfigValue<T>` from the in-memory cache; the cache is warmed on the first `getValue` / `override` / `fetch` call, and returns `Source.DEFAULT` until then.
- `ConfigValues.isEnabled(param: ConfigParam<Boolean>): Boolean` — non-suspend extension (replaces the former `suspend` variant). Delegates to `getValueCached`; safe to call from Composable functions, `init` blocks, and non-coroutine contexts.

- Featured library plugin now publishes a per-module feature-flag manifest as a consumable Gradle artifact (`featuredManifest` configuration, schema v1). Existing flag-generation pipeline is unchanged. Consumer-side aggregation arrives in a follow-up release.
- New `dev.androidbroadcast.featured.application` Gradle plugin: aggregates `featured-manifest.json` artifacts from project dependencies declared via `featuredAggregation(project(...))` and generates `object GeneratedFeaturedRegistry { val all: List<ConfigParam<*>> }` in `build/generated/featured/commonMain/`. Apply alongside `dev.androidbroadcast.featured` in the application module; wire the output directory into your source set manually (e.g., `kotlin.sourceSets.commonMain.kotlin.srcDir(...)`). Modules declaring `enum` flags also require a regular `implementation(project(...))` dependency in the consumer so the enum class is on the compile classpath; primitive-only modules need only `featuredAggregation(...)`.
- Three KMP sample feature modules — `:sample:feature-checkout`, `:sample:feature-promotions`, `:sample:feature-ui` — each declaring its own flags via the `featured { ... }` DSL. Serves as the canonical multi-module reference.
Expand All @@ -30,6 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Restored R8 per-function DCE: ProGuard `-assumevalues` rules now target the actual Kotlin-compiled class name (`GeneratedFlagExtensionsXKt`). The rules were silently no-op since `@file:JvmName` was removed in an earlier PR; unused boolean flags are once again eliminated at shrinking time.
- iOS framework can now `export(project(":sample:feature-*"))` without the K/N `ObjCExportCodeGenerator` crashing — requires `api(project(...))` linkage in the aggregator module so K/N has access to type adapters for generic `ConfigParam<E>` specializations.

## [1.0.0-Beta1] - 2026-05-17
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,29 +6,24 @@ package dev.androidbroadcast.featured.gradle
*
* **Local Boolean flags** get an `is…Enabled()` extension returning the raw `Boolean`:
* ```kotlin
* internal suspend fun ConfigValues.isDarkModeEnabled(): Boolean = getValue(GeneratedLocalFlags.darkMode).value
* internal fun ConfigValues.isDarkModeEnabled(): Boolean = getValueCached(GeneratedLocalFlags.darkMode).value
* ```
*
* **Local non-Boolean flags** get a `get…()` extension returning the raw value type:
* ```kotlin
* internal suspend fun ConfigValues.getMaxRetries(): Int = getValue(GeneratedLocalFlags.maxRetries).value
* internal fun ConfigValues.getMaxRetries(): Int = getValueCached(GeneratedLocalFlags.maxRetries).value
* ```
*
* **Remote flags** get a `get…()` extension returning `ConfigValue<T>` so callers can
* inspect the value source (DEFAULT / REMOTE / etc.):
* ```kotlin
* internal suspend fun ConfigValues.getPromoBannerEnabled(): ConfigValue<Boolean> =
* getValue(GeneratedRemoteFlags.promoBannerEnabled)
* internal fun ConfigValues.getPromoBannerEnabled(): ConfigValue<Boolean> =
* getValueCached(GeneratedRemoteFlags.promoBannerEnabled)
* ```
*
* 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. The `suspend` modifier is required because
* `ConfigValues.getValue` is a `suspend` function.
*
* Note: the ProGuard `-assumevalues` rules emitted by [ProguardRulesGenerator] target the
* non-suspend JVM signature and are therefore **no-ops** for the current generated shape.
* This is a known follow-up item — see tracked issue for the per-function DCE rework.
* now-`public` generated objects.
*
* **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 Expand Up @@ -57,18 +52,6 @@ public object ExtensionFunctionGenerator {
*/
public fun fileName(modulePath: String): String = "GeneratedFlagExtensions${modulePath.modulePathToFileSuffix()}.kt"

/**
* Returns the legacy `@file:JvmName` value that was previously emitted into the source file.
*
* This function is retained for use by [ProguardRulesGenerator], which needs to reference
* the JVM class name in `-assumevalues` rules. Note that those rules are currently no-ops
* because the generated extensions are `suspend` (ProGuard rework is a follow-up item).
*
* Examples: `":app"` → `"FeaturedApp_FlagExtensionsKt"`,
* `":feature:checkout"` → `"FeaturedFeatureCheckout_FlagExtensionsKt"`.
*/
public fun jvmFileName(modulePath: String): String = "Featured${modulePath.modulePathToIdentifier()}_FlagExtensionsKt"

/**
* Generates the full source text for the module-specific `GeneratedFlagExtensions<Suffix>.kt`.
*
Expand Down Expand Up @@ -111,12 +94,12 @@ public object ExtensionFunctionGenerator {
val objectRef = if (isLocal) localObjectName else remoteObjectName
return if (isLocal) {
val funcName = extensionFunctionName()
"internal suspend fun ConfigValues.$funcName(): $type = getValue($objectRef.$propertyName).value\n"
"internal fun ConfigValues.$funcName(): $type = getValueCached($objectRef.$propertyName).value\n"
} else {
// Remote flags always use get… regardless of type — the return is ConfigValue<T>,
// so callers can inspect the value source.
val funcName = "get${propertyName.capitalized()}"
"internal suspend fun ConfigValues.$funcName(): ConfigValue<$type> = getValue($objectRef.$propertyName)\n"
"internal fun ConfigValues.$funcName(): ConfigValue<$type> = getValueCached($objectRef.$propertyName)\n"
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ package dev.androidbroadcast.featured.gradle
*
* Example output for a Boolean flag `dark_mode = false` in module `:feature:ui`:
* ```proguard
* -assumevalues class dev.androidbroadcast.featured.generated.FeaturedFeatureUi_FlagExtensionsKt {
* -assumevalues class dev.androidbroadcast.featured.generated.GeneratedFlagExtensionsFeatureUiKt {
* boolean isDarkModeEnabled(dev.androidbroadcast.featured.ConfigValues) return false;
* }
* ```
Expand All @@ -31,7 +31,9 @@ public object ProguardRulesGenerator {
* Generates ProGuard `-assumevalues` rules for all local flags in [entries].
*
* [modulePath] is the Gradle module path (e.g. `":feature:ui"`) used to derive
* the JVM class name of the generated extensions file via [ExtensionFunctionGenerator.jvmFileName].
* the JVM class name of the generated extensions file. The class name is derived from
* [ExtensionFunctionGenerator.fileName]: the Kotlin compiler uses the file name
* (without `.kt`) plus the `Kt` suffix as the JVM class name for top-level declarations.
*
* Returns a blank string when [entries] contains no local flags with a supported type.
*/
Expand All @@ -42,7 +44,7 @@ public object ProguardRulesGenerator {
val localEntries = entries.filter { it.isLocal && jvmType(it.type) != null }
if (localEntries.isEmpty()) return ""

val className = "$PACKAGE.${ExtensionFunctionGenerator.jvmFileName(modulePath)}"
val className = "$PACKAGE.${ExtensionFunctionGenerator.fileName(modulePath).removeSuffix(".kt")}Kt"

return buildString {
appendLine("# Auto-generated by Featured Gradle Plugin — do not edit manually.")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,21 +51,22 @@ class ExtensionFunctionGeneratorTest {
fun `generates is…Enabled extension for local boolean flag`() {
val entries = listOf(localEntry("dark_mode", "Boolean"))
val source = ExtensionFunctionGenerator.generate(entries, modulePath)
assertContains(source, "suspend fun ConfigValues.isDarkModeEnabled(): Boolean")
assertContains(source, "fun ConfigValues.isDarkModeEnabled(): Boolean")
}

@Test
fun `local boolean extension returns raw value`() {
fun `local boolean extension returns raw value via getValueCached`() {
val entries = listOf(localEntry("dark_mode", "Boolean"))
val source = ExtensionFunctionGenerator.generate(entries, modulePath)
assertContains(source, "getValue(GeneratedLocalFlagsFeatureCheckout.darkMode).value")
assertContains(source, "getValueCached(GeneratedLocalFlagsFeatureCheckout.darkMode).value")
}

@Test
fun `local boolean extension is internal suspend`() {
fun `local boolean extension is internal non-suspend`() {
val entries = listOf(localEntry("dark_mode", "Boolean"))
val source = ExtensionFunctionGenerator.generate(entries, modulePath)
assertContains(source, "internal suspend fun ConfigValues.isDarkModeEnabled()")
assertContains(source, "internal fun ConfigValues.isDarkModeEnabled()")
assertFalse(source.contains("suspend fun ConfigValues.isDarkModeEnabled()"), "Must not emit suspend modifier")
}

// ── local non-boolean flag ────────────────────────────────────────────────
Expand All @@ -74,15 +75,15 @@ class ExtensionFunctionGeneratorTest {
fun `generates get… extension for local int flag`() {
val entries = listOf(localEntry("max_retries", "Int"))
val source = ExtensionFunctionGenerator.generate(entries, modulePath)
assertContains(source, "suspend fun ConfigValues.getMaxRetries(): Int")
assertContains(source, "getValue(GeneratedLocalFlagsFeatureCheckout.maxRetries).value")
assertContains(source, "fun ConfigValues.getMaxRetries(): Int")
assertContains(source, "getValueCached(GeneratedLocalFlagsFeatureCheckout.maxRetries).value")
}

@Test
fun `generates get… extension for local string flag`() {
val entries = listOf(localEntry("api_url", "String"))
val source = ExtensionFunctionGenerator.generate(entries, modulePath)
assertContains(source, "suspend fun ConfigValues.getApiUrl(): String")
assertContains(source, "fun ConfigValues.getApiUrl(): String")
}

// ── local enum flag ───────────────────────────────────────────────────────
Expand All @@ -91,8 +92,8 @@ class ExtensionFunctionGeneratorTest {
fun `generates get… extension for local enum flag`() {
val entries = listOf(localEntry("checkout_variant", "com.example.CheckoutVariant"))
val source = ExtensionFunctionGenerator.generate(entries, modulePath)
assertContains(source, "suspend fun ConfigValues.getCheckoutVariant(): com.example.CheckoutVariant")
assertContains(source, "getValue(GeneratedLocalFlagsFeatureCheckout.checkoutVariant).value")
assertContains(source, "fun ConfigValues.getCheckoutVariant(): com.example.CheckoutVariant")
assertContains(source, "getValueCached(GeneratedLocalFlagsFeatureCheckout.checkoutVariant).value")
}

@Test
Expand All @@ -108,8 +109,9 @@ class ExtensionFunctionGeneratorTest {
fun `generates get… extension returning ConfigValue for remote flag`() {
val entries = listOf(remoteEntry("promo_banner", "Boolean"))
val source = ExtensionFunctionGenerator.generate(entries, modulePath)
assertContains(source, "suspend fun ConfigValues.getPromoBanner(): ConfigValue<Boolean>")
assertContains(source, "getValue(GeneratedRemoteFlagsFeatureCheckout.promoBanner)")
assertContains(source, "fun ConfigValues.getPromoBanner(): ConfigValue<Boolean>")
assertContains(source, "getValueCached(GeneratedRemoteFlagsFeatureCheckout.promoBanner)")
assertFalse(source.contains("suspend "), "Must not emit suspend modifier anywhere")
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,13 +189,14 @@ class FeaturedPluginIntegrationTest {
*
* Expected output (from [ProguardRulesGenerator]):
* ```proguard
* -assumevalues class dev.androidbroadcast.featured.generated.FeaturedRoot_FlagExtensionsKt {
* -assumevalues class dev.androidbroadcast.featured.generated.GeneratedFlagExtensionsRootKt {
* boolean isDarkModeEnabled(dev.androidbroadcast.featured.ConfigValues) return false;
* }
* ```
*
* The root module path `:` produces the identifier `Root` via [String.modulePathToIdentifier],
* so the JVM class name is `FeaturedRoot_FlagExtensionsKt`.
* The root module path `:` produces the file suffix `Root` via [String.modulePathToFileSuffix],
* so the Kotlin file is `GeneratedFlagExtensionsRoot.kt` and the JVM class name
* (Kotlin's file-to-class convention) is `GeneratedFlagExtensionsRootKt`.
*
* Enum flags (`checkout_variant`) must not appear in `-assumevalues` rules — their values
* are resolved at runtime from providers and cannot be assumed at build time (issue #162).
Expand Down Expand Up @@ -281,9 +282,10 @@ class FeaturedPluginIntegrationTest {

private companion object {
// The fixture is a single-project (root) build.
// modulePathToIdentifier(":") → "Root" → jvmFileName → "FeaturedRoot_FlagExtensionsKt"
// modulePathToFileSuffix(":") → "Root" → fileName → "GeneratedFlagExtensionsRoot.kt"
// → JVM class: "GeneratedFlagExtensionsRootKt"
const val EXTENSIONS_FQN =
"dev.androidbroadcast.featured.generated.FeaturedRoot_FlagExtensionsKt"
"dev.androidbroadcast.featured.generated.GeneratedFlagExtensionsRootKt"
const val CONFIG_VALUES_FQN = "dev.androidbroadcast.featured.ConfigValues"
const val IS_DARK_MODE_ENABLED = "isDarkModeEnabled"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import kotlin.test.assertTrue
class ProguardRulesGeneratorTest {
private val modulePath = ":feature:ui"
private val expectedClass =
"dev.androidbroadcast.featured.generated.${ExtensionFunctionGenerator.jvmFileName(modulePath)}"
"dev.androidbroadcast.featured.generated.${ExtensionFunctionGenerator.fileName(modulePath).removeSuffix(".kt")}Kt"

// ── empty / no-op cases ──────────────────────────────────────────────────

Expand Down
Loading
Loading