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
12 changes: 11 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- `featured-registry` module — the runtime `FlagRegistry` global singleton and its `FlagRegistryDelegate` expect/actual are removed. Use `GeneratedFeaturedRegistry.all` (produced by the `dev.androidbroadcast.featured.application` plugin) or build an explicit `List<ConfigParam<*>>` instead.
- `featured-gradle-plugin` — `generateFlagRegistrar` task, `FlagRegistrarGenerator`, and `GenerateFlagRegistrarTask` are removed. Per-module `GeneratedFlagRegistrar.kt` files are no longer generated.
- Sample API — `registerSampleFlags()` is removed (was specific to the sample app's legacy wiring). The sample now uses `SampleFeatureFlags.all` directly.
- Sample API — `registerSampleFlags()` is removed (was specific to the sample app's legacy wiring). The sample now uses `GeneratedFeaturedRegistry.all` (produced by the aggregator plugin) instead.

### Changed

- `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.

### Added

- 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.
- `EnumDropdown` component in `featured-debug-ui` for overriding `enum`-typed flags in `FeatureFlagsDebugScreen`; `ConfigParam<E>` now carries `enumConstants: List<E>?` populated by codegen so the debug UI can render the dropdown without reflection.
- `featured-gradle-plugin` extracted to a `build-logic/` included build; `pluginManagement { includeBuild("build-logic") }` in the root `settings.gradle.kts` exposes it to all main-build subprojects without a version coordinate.

### Fixed

- 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
@@ -0,0 +1,120 @@
package dev.androidbroadcast.featured.gradle

/**
* Generates `GeneratedLocalFlags<Suffix>.kt` and `GeneratedRemoteFlags<Suffix>.kt` — public
* 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")
* }
* ```
*
* 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].
*/
public object ConfigParamGenerator {
private const val PACKAGE = "dev.androidbroadcast.featured.generated"
private const val CONFIG_PARAM_IMPORT = "dev.androidbroadcast.featured.ConfigParam"
private const val LOCAL_OBJECT_PREFIX = "GeneratedLocalFlags"
private const val REMOTE_OBJECT_PREFIX = "GeneratedRemoteFlags"

/**
* Returns the generated object name for local flags in the given module.
*
* Examples:
* - `":app"` → `"GeneratedLocalFlagsApp"`
* - `":sample:feature-checkout"` → `"GeneratedLocalFlagsSampleFeatureCheckout"`
*/
public fun localObjectName(modulePath: String): String = "$LOCAL_OBJECT_PREFIX${modulePath.modulePathToFileSuffix()}"

/**
* Returns the generated object name for remote flags in the given module.
*
* Examples:
* - `":app"` → `"GeneratedRemoteFlagsApp"`
* - `":sample:feature-promotions"` → `"GeneratedRemoteFlagsSampleFeaturePromotions"`
*/
public fun remoteObjectName(modulePath: String): String = "$REMOTE_OBJECT_PREFIX${modulePath.modulePathToFileSuffix()}"

/**
* Returns the emitted `.kt` file name for the local-flags object of the given module.
*
* The object name is derived from the module path, making the JVM class name unique per module.
*/
public fun localFileName(modulePath: String): String = "${localObjectName(modulePath)}.kt"

/**
* Returns the emitted `.kt` file name for the remote-flags object of the given module.
*
* The object name is derived from the module path, making the JVM class name unique per module.
*/
public fun remoteFileName(modulePath: String): String = "${remoteObjectName(modulePath)}.kt"

/**
* Generates the Kotlin source for the module-specific local-flags and remote-flags objects
* as a pair.
*
* The object names and file names include a module-derived suffix (see [localObjectName] /
* [remoteObjectName]) so that each module's classes are unique at the JVM level.
*
* Returns a pair of `(localSource, remoteSource)`. Either may be an empty string
* if there are no flags of that type.
*/
public fun generate(
entries: List<LocalFlagEntry>,
modulePath: String,
): Pair<String, String> {
val (local, remote) = entries.partition { it.isLocal }
return generateObject(local, localObjectName(modulePath)) to
generateObject(remote, remoteObjectName(modulePath))
}

private fun generateObject(
entries: List<LocalFlagEntry>,
objectName: String,
): String {
if (entries.isEmpty()) return ""
return buildString {
appendLine("// Auto-generated by Featured Gradle Plugin — do not edit manually.")
appendLine("package $PACKAGE")
appendLine()
appendLine("import $CONFIG_PARAM_IMPORT")
appendLine()
appendLine("public object $objectName {")
entries.forEach { entry ->
appendLine(" public val ${entry.propertyName} = ${entry.toConfigParamExpression()}")
}
append("}")
}
}

private fun LocalFlagEntry.toConfigParamExpression(): String {
val typeArg = type
val namedArgs =
buildList {
add("key = \"$key\"")
add("defaultValue = ${formatDefault()}")
if (description != null) add("description = \"$description\"")
if (category != null) add("category = \"$category\"")
Comment on lines +99 to +106
if (isEnum) add("enumConstants = kotlin.enums.enumEntries<$type>()")
}
return "ConfigParam<$typeArg>(${namedArgs.joinToString(", ")})"
}

private fun LocalFlagEntry.formatDefault(): String =
when {
isEnum -> "$type.$defaultValue"
type == "String" -> defaultValue
type == "Long" -> "${defaultValue}L"
type == "Float" -> "${defaultValue}f"
else -> defaultValue
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package dev.androidbroadcast.featured.gradle

/**
* Generates the `GeneratedFlagExtensions<Suffix>.kt` source file — internal extension
* functions on `ConfigValues` for each declared flag.
*
* **Local Boolean flags** get an `is…Enabled()` extension returning the raw `Boolean`:
* ```kotlin
* internal suspend fun ConfigValues.isDarkModeEnabled(): Boolean = getValue(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
* ```
*
* **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)
* ```
*
* 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.
*
* **JVM class-name uniqueness:** `@file:JvmName` is intentionally absent — it is not
* supported on Kotlin/Native targets. Instead, the emitted file is named
* `GeneratedFlagExtensions<Suffix>.kt` where `<Suffix>` is derived from the Gradle module
* path (e.g. `SampleFeatureCheckout`). The Kotlin compiler derives the JVM class name from
* the file name, so `GeneratedFlagExtensionsSampleFeatureCheckoutKt` is unique per module
* without any JVM-specific annotation.
*/
public object ExtensionFunctionGenerator {
private const val PACKAGE = "dev.androidbroadcast.featured.generated"
private const val CONFIG_VALUES_IMPORT = "dev.androidbroadcast.featured.ConfigValues"
private const val CONFIG_VALUE_IMPORT = "dev.androidbroadcast.featured.ConfigValue"

/**
* Returns the emitted `.kt` file name for the given Gradle module path.
*
* The file-name suffix is derived by splitting the module path on all non-alphanumeric
* characters and PascalCasing each segment, ensuring a valid Kotlin identifier without
* any JVM-specific annotation. The Kotlin compiler derives the JVM class name from this
* file name (e.g. `GeneratedFlagExtensionsSampleFeatureCheckoutKt`).
*
* Examples:
* - `":app"` → `"GeneratedFlagExtensionsApp.kt"`
* - `":feature:checkout"` → `"GeneratedFlagExtensionsFeatureCheckout.kt"`
* - `":sample:feature-checkout"` → `"GeneratedFlagExtensionsSampleFeatureCheckout.kt"`
*/
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`.
*
* Returns an empty string if [entries] is empty.
*/
public fun generate(
entries: List<LocalFlagEntry>,
modulePath: String,
): String {
if (entries.isEmpty()) return ""
val needsConfigValue = entries.any { !it.isLocal }
val localObjectName = ConfigParamGenerator.localObjectName(modulePath)
val remoteObjectName = ConfigParamGenerator.remoteObjectName(modulePath)

return buildString {
appendLine("// Auto-generated by Featured Gradle Plugin — do not edit manually.")
appendLine()
appendLine("package $PACKAGE")
appendLine()
appendLine("import $CONFIG_VALUES_IMPORT")
if (needsConfigValue) appendLine("import $CONFIG_VALUE_IMPORT")
val (localEntries, remoteEntries) = entries.partition { it.isLocal }
if (localEntries.isNotEmpty()) {
appendLine("import $PACKAGE.$localObjectName")
}
if (remoteEntries.isNotEmpty()) {
appendLine("import $PACKAGE.$remoteObjectName")
}
appendLine()
entries.forEach { entry ->
appendLine(entry.toExtensionFunction(localObjectName, remoteObjectName))
}
}.trimEnd() + "\n"
}

private fun LocalFlagEntry.toExtensionFunction(
localObjectName: String,
remoteObjectName: String,
): String {
val objectRef = if (isLocal) localObjectName else remoteObjectName
return if (isLocal) {
val funcName = extensionFunctionName()
"internal suspend fun ConfigValues.$funcName(): $type = getValue($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"
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,11 @@ internal fun String.toCamelCase(): String =
}.joinToString("")

/**
* Derives a unique JVM class name suffix from a Gradle module path, used in
* `@file:JvmName(...)` to prevent class name conflicts across modules.
* Derives a PascalCase identifier from a Gradle module path.
*
* Splits on `:` only; segments containing hyphens or other special characters are
* preserved as single PascalCase words (e.g. `"feature-checkout"` → `"Feature-checkout"`).
* Used internally for identifier derivation.
*
* Examples:
* - `":app"` → `"App"`
Expand All @@ -30,6 +33,26 @@ internal fun String.modulePathToIdentifier(): String =
.joinToString("") { segment -> segment.replaceFirstChar { it.uppercase() } }
.ifEmpty { "Root" }

/**
* Derives a PascalCase file-name suffix from a Gradle module path, safe for use as a
* Kotlin source-file name component.
*
* Unlike [modulePathToIdentifier], this function splits on ALL non-alphanumeric characters
* (`:`, `-`, `.`, `_`, etc.) so that path segments like `"feature-checkout"` produce
* `"FeatureCheckout"` rather than `"Feature-checkout"`.
*
* Examples:
* - `":app"` → `"App"`
* - `":feature:checkout"` → `"FeatureCheckout"`
* - `":sample:feature-checkout"` → `"SampleFeatureCheckout"`
* - `":"` → `"Root"`
*/
internal fun String.modulePathToFileSuffix(): String =
split(Regex("[^A-Za-z0-9]+"))
.filter { it.isNotBlank() }
.joinToString("") { segment -> segment.replaceFirstChar { it.uppercase() } }
.ifEmpty { "Root" }

internal fun String.capitalized(): String = replaceFirstChar { it.uppercase() }

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@ import org.gradle.api.tasks.TaskAction
/**
* Gradle task that reads the [ResolveFlagsTask] output and generates three Kotlin source files:
*
* - `GeneratedLocalFlags.kt` — internal object with one `ConfigParam` per local flag.
* - `GeneratedRemoteFlags.kt` — internal object with one `ConfigParam` per remote flag.
* - `GeneratedFlagExtensions.kt` — public extension functions on `ConfigValues`, one per flag.
* - `GeneratedLocalFlags<Suffix>.kt` — public object with one `ConfigParam` per local flag.
* - `GeneratedRemoteFlags<Suffix>.kt` — public object with one `ConfigParam` per remote flag.
* - `GeneratedFlagExtensions<Suffix>.kt` — internal extension functions on `ConfigValues`,
* one per flag. The suffix is derived from [modulePath] (e.g. `SampleFeatureCheckout`)
* so that each module's file produces a unique JVM class name.
*
* All files are written to [outputDir] (`build/generated/featured/commonMain/`).
* Add [outputDir] to the Kotlin compilation source set:
Expand All @@ -36,7 +38,7 @@ public abstract class GenerateConfigParamTask : DefaultTask() {
@get:PathSensitive(PathSensitivity.NONE)
public abstract val flagsFile: RegularFileProperty

/** The Gradle module path used to derive the `@file:JvmName` suffix. */
/** The Gradle module path (e.g. `":sample:feature-checkout"`) used to derive the file-name suffix. */
@get:Input
public abstract val modulePath: Property<String>

Expand All @@ -48,19 +50,24 @@ public abstract class GenerateConfigParamTask : DefaultTask() {
public fun generate() {
val entries = flagsFile.parseLocalFlagEntries()
val dir = outputDir.get().asFile
// Clean before writing — the extension file name changed from the fixed
// "GeneratedFlagExtensions.kt" to a module-specific name, so stale files
// from previous runs must be removed to avoid duplicate-class compile errors.
dir.deleteRecursively()
dir.mkdirs()

val (localSource, remoteSource) = ConfigParamGenerator.generate(entries)
val extensionsSource = ExtensionFunctionGenerator.generate(entries, modulePath.get())
val path = modulePath.get()
val (localSource, remoteSource) = ConfigParamGenerator.generate(entries, path)
val extensionsSource = ExtensionFunctionGenerator.generate(entries, path)

if (localSource.isNotEmpty()) {
dir.resolve("GeneratedLocalFlags.kt").writeText(localSource)
dir.resolve(ConfigParamGenerator.localFileName(path)).writeText(localSource)
}
if (remoteSource.isNotEmpty()) {
dir.resolve("GeneratedRemoteFlags.kt").writeText(remoteSource)
dir.resolve(ConfigParamGenerator.remoteFileName(path)).writeText(remoteSource)
}
if (extensionsSource.isNotEmpty()) {
dir.resolve("GeneratedFlagExtensions.kt").writeText(extensionsSource)
dir.resolve(ExtensionFunctionGenerator.fileName(modulePath.get())).writeText(extensionsSource)
}

val local = entries.count { it.isLocal }
Expand Down
Loading
Loading