From 24759169385f0457bf04ca5ec964c2a2ff089a99 Mon Sep 17 00:00:00 2001 From: Kirill Rozov Date: Mon, 18 May 2026 08:26:14 +0300 Subject: [PATCH 01/15] Add develop branch to CI pipeline triggers (#192) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add develop branch to CI pipeline triggers Mirror main triggers onto develop for the new gitflow integration branch: ci, codeql, dependency-review build/scan on push/PR to develop, docs site is built on develop but published to Pages only from main or release tags, and publish.yml emits SNAPSHOTs from both branches while tagged release publication remains main-only. * Address review: skip CodeQL on workflow-only PRs, fix tag triggers CodeQL diff-range scanner fails with "no source code seen during build" when a PR touches no Java/Kotlin sources. Add paths-ignore for workflow, docs, and markdown files so docs/CI-config PRs no longer break the scan; weekly schedule still does a full scan. Move docs.yml tag patterns from pull_request to push — pull_request does not support tag filters, so tag-driven docs publication was unreachable despite the publish-docs if: clause checking for refs/tags/. --- .github/workflows/ci.yml | 4 ++-- .github/workflows/codeql.yml | 12 ++++++++++-- .github/workflows/dependency-review.yml | 2 +- .github/workflows/docs.yml | 12 +++++++----- .github/workflows/publish.yml | 1 + 5 files changed, 21 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f35f532..328bb40 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [ main ] + branches: [ main, develop ] pull_request: - branches: [ main ] + branches: [ main, develop ] permissions: contents: read diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 9ae0ba7..f560357 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -2,9 +2,17 @@ name: CodeQL on: push: - branches: [ main ] + branches: [ main, develop ] + paths-ignore: + - '.github/workflows/**' + - 'docs/**' + - '**/*.md' pull_request: - branches: [ main ] + branches: [ main, develop ] + paths-ignore: + - '.github/workflows/**' + - 'docs/**' + - '**/*.md' schedule: - cron: "0 0 * * 0" # Every Sunday at midnight diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 4388ac5..7646f06 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -2,7 +2,7 @@ name: Dependency Review on: pull_request: - branches: [ main ] + branches: [ main, develop ] permissions: contents: read diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index a5624cd..3c0031e 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -4,12 +4,14 @@ on: push: branches: - main - pull_request: - branches: - - main + - develop tags: - "v[0-9]+.[0-9]+.[0-9]+" - "v[0-9]+.[0-9]+.[0-9]+-*" + pull_request: + branches: + - main + - develop permissions: contents: write @@ -58,8 +60,8 @@ jobs: name: Publish to GitHub Pages runs-on: ubuntu-latest needs: build-docs - # Only publish on pushes to main or on release tags — not on PRs - if: github.event_name == 'push' + # Only publish on pushes to main or on release tags — not on PRs or develop + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')) steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f95a3bc..ee87f8f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -7,6 +7,7 @@ on: - "v[0-9]+.[0-9]+.[0-9]+-*" branches: - main + - develop permissions: contents: read From 5b9170928e4523738f9d043b95cf2f0f9f2154c1 Mon Sep 17 00:00:00 2001 From: Kirill Rozov Date: Mon, 18 May 2026 10:00:41 +0300 Subject: [PATCH 02/15] Rename proguard task to generateFeaturedProguardRules (#190) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Rename proguard task to generateFeaturedProguardRules Namespace the Gradle task with the `featured` prefix to avoid clashes with other plugins that register a similarly named task. The Kotlin class GenerateProguardRulesTask and the GENERATE_PROGUARD_TASK_NAME constant identifier stay the same — only the task name string and its references in tests and docs change. * Address review: changelog Unreleased entry and README scanAllLocalFlags Add `### Changed` entry under `## Unreleased` documenting the task rename with a one-line migration note. Append `scanAllLocalFlags` to the parenthesized task list in the Configuration Cache section of README so it matches the set of tasks actually registered by the plugin. Refs PR #190 review comments from qodo-code-review and copilot-pull-request-reviewer. --- README.md | 6 +++--- docs/api/index.md | 2 +- docs/changelog.md | 6 ++++++ docs/guides/android.md | 2 +- docs/guides/best-practices.md | 2 +- featured-gradle-plugin/CLAUDE.md | 2 +- .../featured/gradle/FeaturedPlugin.kt | 2 +- .../gradle/FeaturedPluginIntegrationTest.kt | 16 ++++++++-------- .../GenerateProguardRulesTaskRegistrationTest.kt | 10 +++++----- 9 files changed, 27 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 9bf67df..c59cf15 100644 --- a/README.md +++ b/README.md @@ -362,7 +362,7 @@ The Gradle plugin generates per-function ProGuard / R8 `-assumevalues` rules for The task runs automatically when you build a release variant. To run it manually: ```bash -./gradlew :app:generateProguardRules +./gradlew :app:generateFeaturedProguardRules ``` Output: `app/build/featured/proguard-featured.pro` @@ -490,7 +490,7 @@ Run code generation tasks across all modules at once: ./gradlew scanAllLocalFlags # Generate R8 rules for all Android modules -./gradlew generateProguardRules +./gradlew generateFeaturedProguardRules # Generate xcconfig across all modules ./gradlew generateXcconfig @@ -502,7 +502,7 @@ Declare a single shared `ConfigValues` in your app module and inject it into fea ## Configuration cache -`featured-gradle-plugin` officially supports the Gradle [Configuration Cache](https://docs.gradle.org/current/userguide/configuration_cache.html) on **Gradle 9+** and **AGP 9+**. Every task registered by the plugin (`resolveFeatureFlags`, `generateProguardRules`, `generateConfigParam`, `generateFlagRegistrar`, `generateIosConstVal`, `generateXcconfig`) stores and reuses CC entries without violations. +`featured-gradle-plugin` officially supports the Gradle [Configuration Cache](https://docs.gradle.org/current/userguide/configuration_cache.html) on **Gradle 9+** and **AGP 9+**. Every task registered by the plugin (`resolveFeatureFlags`, `generateFeaturedProguardRules`, `generateConfigParam`, `generateFlagRegistrar`, `generateIosConstVal`, `generateXcconfig`, `scanAllLocalFlags`) stores and reuses CC entries without violations. ### Enabling diff --git a/docs/api/index.md b/docs/api/index.md index 6615402..e63d523 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -121,7 +121,7 @@ The plugin generates: | `resolveFeatureFlags` | Resolves DSL-declared flags; runs before all code-generation tasks | | `generateConfigParam` | Generates `GeneratedLocalFlags` and `GeneratedRemoteFlags` objects | | `generateFlagRegistrar` | Generates flag registrar for the debug UI | -| `generateProguardRules` | Generates per-function R8 `-assumevalues` rules for local boolean flags | +| `generateFeaturedProguardRules` | Generates per-function R8 `-assumevalues` rules for local boolean flags | | `generateIosConstVal` | Generates `expect`/`actual const val` for local flags (iOS) | | `generateXcconfig` | Generates xcconfig with `DISABLE_*` conditions for local boolean flags | | `scanAllLocalFlags` | Aggregator task — collects flags across all modules | diff --git a/docs/changelog.md b/docs/changelog.md index 1828e02..e14a179 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -11,6 +11,12 @@ For the full release history with diff links, see the _Changes on `main` not yet tagged for release._ +### Changed +- Renamed the Gradle ProGuard/R8 generation task from `generateProguardRules` to + `generateFeaturedProguardRules` to avoid task-name clashes with consumer scripts. + Migration: update any CI/build scripts that invoke `generateProguardRules` to use + the new name. The old task name is no longer registered. (#190) + --- ## Contributing a changelog entry diff --git a/docs/guides/android.md b/docs/guides/android.md index 9988731..001d6b4 100644 --- a/docs/guides/android.md +++ b/docs/guides/android.md @@ -245,7 +245,7 @@ The Gradle plugin generates per-function `-assumevalues` rules for the generated The task runs automatically when you build a release variant. To run it manually: ```bash -./gradlew :app:generateProguardRules +./gradlew :app:generateFeaturedProguardRules ``` Output: `app/build/featured/proguard-featured.pro` diff --git a/docs/guides/best-practices.md b/docs/guides/best-practices.md index 00d054a..5c60b97 100644 --- a/docs/guides/best-practices.md +++ b/docs/guides/best-practices.md @@ -66,7 +66,7 @@ Once the feature is fully rolled out and validated: 4. Regenerate platform artefacts: ```bash -./gradlew generateProguardRules # keep Android R8 rules in sync +./gradlew generateFeaturedProguardRules # keep Android R8 rules in sync ./gradlew generateXcconfig # keep iOS xcconfig in sync ``` diff --git a/featured-gradle-plugin/CLAUDE.md b/featured-gradle-plugin/CLAUDE.md index a1858b2..5f50182 100644 --- a/featured-gradle-plugin/CLAUDE.md +++ b/featured-gradle-plugin/CLAUDE.md @@ -28,7 +28,7 @@ featured { | `resolveFeatureFlags` | `build/featured/flags.txt` | | `generateConfigParam` | `build/generated/featured/commonMain/Generated{Local,Remote}Flags.kt` + `GeneratedFlagExtensions.kt` | | `generateFlagRegistrar` | `build/generated/featured/GeneratedFlagRegistrar.kt` | -| `generateProguardRules` | `build/featured/proguard-featured.pro` | +| `generateFeaturedProguardRules` | `build/featured/proguard-featured.pro` | | `generateIosConstVal` | iOS constant value files | | `generateXcconfig` | `build/featured/FeatureFlags.generated.xcconfig` | diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPlugin.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPlugin.kt index 6496396..95d9425 100644 --- a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPlugin.kt +++ b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPlugin.kt @@ -7,7 +7,7 @@ import org.gradle.api.tasks.TaskProvider internal const val RESOLVE_FLAGS_TASK_NAME = "resolveFeatureFlags" internal const val SCAN_ALL_TASK_NAME = "scanAllLocalFlags" internal const val GENERATE_FLAG_REGISTRAR_TASK_NAME = "generateFlagRegistrar" -internal const val GENERATE_PROGUARD_TASK_NAME = "generateProguardRules" +internal const val GENERATE_PROGUARD_TASK_NAME = "generateFeaturedProguardRules" internal const val GENERATE_IOS_CONST_VAL_TASK_NAME = "generateIosConstVal" internal const val GENERATE_XCCONFIG_TASK_NAME = "generateXcconfig" internal const val GENERATE_CONFIG_PARAM_TASK_NAME = "generateConfigParam" diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPluginIntegrationTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPluginIntegrationTest.kt index 7088533..ede0348 100644 --- a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPluginIntegrationTest.kt +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPluginIntegrationTest.kt @@ -15,7 +15,7 @@ import kotlin.test.assertTrue * End-to-end integration test that verifies the Featured Gradle plugin: * 1. Generates a ProGuard file at `build/featured/proguard-featured.pro` with correct * `-assumevalues` rules for declared local flags. - * 2. Auto-wires that file into the AGP release variant so the `generateProguardRules` + * 2. Auto-wires that file into the AGP release variant so the `generateFeaturedProguardRules` * task participates in `assembleRelease`. * * The test uses a minimal Android application fixture copied from @@ -49,17 +49,17 @@ class FeaturedPluginIntegrationTest { // ── Tests ───────────────────────────────────────────────────────────────── @Test - fun `generateProguardRules task produces correct assumevalues rule for boolean local flag`() { + fun `generateFeaturedProguardRules task produces correct assumevalues rule for boolean local flag`() { val result = gradleRunner(projectDir) - .withArguments("generateProguardRules", "--stacktrace") + .withArguments("generateFeaturedProguardRules", "--stacktrace") .build() - val outcome = result.task(":generateProguardRules")?.outcome + val outcome = result.task(":generateFeaturedProguardRules")?.outcome assertEquals( TaskOutcome.SUCCESS, outcome, - "Expected :generateProguardRules to succeed, got $outcome\n${result.output}", + "Expected :generateFeaturedProguardRules to succeed, got $outcome\n${result.output}", ) val proFile = projectDir.resolve("build/featured/proguard-featured.pro") @@ -138,13 +138,13 @@ class FeaturedPluginIntegrationTest { .withArguments(args) .build() - // generateProguardRules must have run as part of the release build. - val proguardOutcome = result.task(":generateProguardRules")?.outcome + // generateFeaturedProguardRules must have run as part of the release build. + val proguardOutcome = result.task(":generateFeaturedProguardRules")?.outcome assertTrue( proguardOutcome == TaskOutcome.SUCCESS || proguardOutcome == TaskOutcome.UP_TO_DATE || proguardOutcome == TaskOutcome.FROM_CACHE, - "Expected :generateProguardRules to participate in assembleRelease (cc=$cc), got $proguardOutcome\n${result.output}", + "Expected :generateFeaturedProguardRules to participate in assembleRelease (cc=$cc), got $proguardOutcome\n${result.output}", ) // On the second CC-enabled run, the cache is reused AND all task outputs are unchanged, diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/GenerateProguardRulesTaskRegistrationTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/GenerateProguardRulesTaskRegistrationTest.kt index 60bc461..0afbe13 100644 --- a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/GenerateProguardRulesTaskRegistrationTest.kt +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/GenerateProguardRulesTaskRegistrationTest.kt @@ -8,7 +8,7 @@ import kotlin.test.assertTrue class GenerateProguardRulesTaskRegistrationTest { @Test - fun `plugin registers generateProguardRules task`() { + fun `plugin registers generateFeaturedProguardRules task`() { val project = ProjectBuilder.builder().build() project.plugins.apply("dev.androidbroadcast.featured") @@ -19,7 +19,7 @@ class GenerateProguardRulesTaskRegistrationTest { } @Test - fun `generateProguardRules task is of correct type`() { + fun `generateFeaturedProguardRules task is of correct type`() { val project = ProjectBuilder.builder().build() project.plugins.apply("dev.androidbroadcast.featured") @@ -29,7 +29,7 @@ class GenerateProguardRulesTaskRegistrationTest { } @Test - fun `generateProguardRules task is in featured group`() { + fun `generateFeaturedProguardRules task is in featured group`() { val project = ProjectBuilder.builder().build() project.plugins.apply("dev.androidbroadcast.featured") @@ -39,7 +39,7 @@ class GenerateProguardRulesTaskRegistrationTest { } @Test - fun `generateProguardRules task has outputFile configured`() { + fun `generateFeaturedProguardRules task has outputFile configured`() { val project = ProjectBuilder.builder().build() project.plugins.apply("dev.androidbroadcast.featured") @@ -49,7 +49,7 @@ class GenerateProguardRulesTaskRegistrationTest { } @Test - fun `generateProguardRules task depends on resolveFeatureFlags task`() { + fun `generateFeaturedProguardRules task depends on resolveFeatureFlags task`() { val project = ProjectBuilder.builder().build() project.plugins.apply("dev.androidbroadcast.featured") From 3f43b24adacb434334b80b26504a0043c8bda386 Mon Sep 17 00:00:00 2001 From: Kirill Rozov Date: Mon, 18 May 2026 13:30:46 +0300 Subject: [PATCH 03/15] Fix ConfigValues.observe to catch provider exceptions (#196) ConfigValues KDoc promises that observe wraps provider calls in try/catch and routes errors through onProviderError, but the implementation collected provider Flows raw, so a provider whose Flow throws downstream would crash the host coroutine. Wrap local and remote Flows in .catch { onProviderError } before merging to restore the documented contract. Add a regression test asserting that a throwing local provider does not propagate and that the error is reported. Co-authored-by: Claude Opus 4.7 --- .../androidbroadcast/featured/ConfigValues.kt | 5 +- .../featured/ProviderErrorObserveTest.kt | 90 +++++++++++++++++++ 2 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 core/src/commonTest/kotlin/dev/androidbroadcast/featured/ProviderErrorObserveTest.kt diff --git a/core/src/commonMain/kotlin/dev/androidbroadcast/featured/ConfigValues.kt b/core/src/commonMain/kotlin/dev/androidbroadcast/featured/ConfigValues.kt index ab8df1e..dd755c8 100644 --- a/core/src/commonMain/kotlin/dev/androidbroadcast/featured/ConfigValues.kt +++ b/core/src/commonMain/kotlin/dev/androidbroadcast/featured/ConfigValues.kt @@ -4,6 +4,7 @@ package dev.androidbroadcast.featured import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map @@ -169,8 +170,8 @@ public class ConfigValues( * @return A [Flow] of [ConfigValue] for the specified parameter. */ public fun observe(param: ConfigParam): Flow> { - val localFlow = localProvider?.observe(param) - val remoteFlow = fetchSignal.map { getValue(param) } + val localFlow = localProvider?.observe(param)?.catch { e -> onProviderError(e) } + val remoteFlow = fetchSignal.map { getValue(param) }.catch { e -> onProviderError(e) } return flow> { emit(getValue(param)) diff --git a/core/src/commonTest/kotlin/dev/androidbroadcast/featured/ProviderErrorObserveTest.kt b/core/src/commonTest/kotlin/dev/androidbroadcast/featured/ProviderErrorObserveTest.kt new file mode 100644 index 0000000..f1d61c8 --- /dev/null +++ b/core/src/commonTest/kotlin/dev/androidbroadcast/featured/ProviderErrorObserveTest.kt @@ -0,0 +1,90 @@ +package dev.androidbroadcast.featured + +import app.cash.turbine.test +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ProviderErrorObserveTest { + private val testParam = ConfigParam("observe_error_key", "DEFAULT") + + /** + * Fake local provider whose [observe] emits one value successfully, + * then throws. All other required methods are no-ops. + */ + private class OnceThrowingLocalProvider( + private val emittedValue: String, + private val error: Throwable, + ) : LocalConfigValueProvider { + override suspend fun get(param: ConfigParam): ConfigValue? = null + + override suspend fun set( + param: ConfigParam, + value: T, + ) = Unit + + override suspend fun resetOverride(param: ConfigParam) = Unit + + override suspend fun clear() = Unit + + @Suppress("UNCHECKED_CAST") + override fun observe(param: ConfigParam): Flow> = + flow { + emit(ConfigValue(emittedValue as T, ConfigValue.Source.LOCAL)) + throw error + } + } + + @Test + fun observeDoesNotPropagateWhenLocalProviderFlowThrows() = + runTest { + val errors = mutableListOf() + val provider = + OnceThrowingLocalProvider( + emittedValue = "local_value", + error = IllegalStateException("simulated provider error"), + ) + val configValues = + ConfigValues( + localProvider = provider, + onProviderError = { errors.add(it) }, + ) + + val collected = mutableListOf() + + // Collection must complete without throwing; the Flow stays alive after the + // local provider's Flow terminates with an exception. + configValues.observe(testParam).test { + // Initial emission from getValue(param) — local provider's get() returns null + // so this resolves to the default value. + val initial = awaitItem() + assertEquals("DEFAULT", initial.value) + assertEquals(ConfigValue.Source.DEFAULT, initial.source) + collected.add(initial.value) + + // Emission from the local provider's observe() before it throws. + // distinctUntilChanged passes it because "local_value" ≠ "DEFAULT". + val fromLocal = awaitItem() + assertEquals("local_value", fromLocal.value) + assertEquals(ConfigValue.Source.LOCAL, fromLocal.source) + collected.add(fromLocal.value) + + cancelAndIgnoreRemainingEvents() + } + + // The thrown exception must have been routed to onProviderError, not re-thrown. + assertEquals(1, errors.size) + assertTrue( + errors[0] is IllegalStateException, + "Expected IllegalStateException but was ${errors[0]::class}", + ) + assertEquals("simulated provider error", errors[0].message) + + // Both values were collected — flow did not crash before the second emission. + assertTrue("DEFAULT" in collected) + assertTrue("local_value" in collected) + } +} From b734f8b9f82fc70b4ff03167e79dc41d1a6a7fd5 Mon Sep 17 00:00:00 2001 From: Kirill Rozov Date: Mon, 18 May 2026 13:52:08 +0300 Subject: [PATCH 04/15] Register enum converter for CheckoutVariant in sample (#194) * Register enum converter for CheckoutVariant in sample The sample observes an enum-typed flag (CheckoutVariant) via DataStoreConfigValueProvider, but never registered a TypeConverter for the enum. DataStore preferences only support primitive keys, so observation crashed with IllegalArgumentException on launch. Register enumConverter() on the provider before building ConfigValues. Co-Authored-By: Claude Opus 4.7 * Register sample flags and fix status-bar overlap Register every flag in SampleFeatureFlags with FlagRegistry on Application start so the Debug UI renders the list instead of the empty state. Apply statusBarsPadding() to the home-screen top row so the "Debug flags" button is reachable under enableEdgeToEdge. Co-Authored-By: Claude Opus 4.7 --------- Co-authored-by: Claude Opus 4.7 --- sample/android-app/build.gradle.kts | 1 + .../featured/sample/MainActivity.kt | 15 ++++++++++++++- .../androidbroadcast/featured/FeaturedSample.kt | 2 ++ .../dev/androidbroadcast/featured/SampleApp.kt | 16 ++++++++++++++++ 4 files changed, 33 insertions(+), 1 deletion(-) diff --git a/sample/android-app/build.gradle.kts b/sample/android-app/build.gradle.kts index dc3e224..3f4c463 100644 --- a/sample/android-app/build.gradle.kts +++ b/sample/android-app/build.gradle.kts @@ -48,6 +48,7 @@ dependencies { implementation(project(":sample:shared")) implementation(project(":featured-debug-ui")) implementation(project(":featured-platform")) + implementation(project(":providers:datastore")) implementation(libs.androidx.activity.compose) implementation(libs.androidx.appcompat) } diff --git a/sample/android-app/src/main/kotlin/dev/androidbroadcast/featured/sample/MainActivity.kt b/sample/android-app/src/main/kotlin/dev/androidbroadcast/featured/sample/MainActivity.kt index e9f0813..7156e6b 100644 --- a/sample/android-app/src/main/kotlin/dev/androidbroadcast/featured/sample/MainActivity.kt +++ b/sample/android-app/src/main/kotlin/dev/androidbroadcast/featured/sample/MainActivity.kt @@ -9,17 +9,30 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import dev.androidbroadcast.featured.CheckoutVariant import dev.androidbroadcast.featured.ConfigValues import dev.androidbroadcast.featured.SampleApp +import dev.androidbroadcast.featured.datastore.DataStoreConfigValueProvider +import dev.androidbroadcast.featured.datastore.registerConverter import dev.androidbroadcast.featured.debugui.FeatureFlagsDebugScreen +import dev.androidbroadcast.featured.enumConverter import dev.androidbroadcast.featured.platform.defaultLocalProvider +import dev.androidbroadcast.featured.registerSampleFlags 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 { - ConfigValues(localProvider = defaultLocalProvider(applicationContext)) + // Populate FlagRegistry so FeatureFlagsDebugScreen can discover all flags via FlagRegistry.all(). + registerSampleFlags() + + val localProvider = 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) + ?.registerConverter(enumConverter()) + ConfigValues(localProvider = localProvider) } override fun onCreate(savedInstanceState: Bundle?) { diff --git a/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/FeaturedSample.kt b/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/FeaturedSample.kt index 18399bb..d54e52d 100644 --- a/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/FeaturedSample.kt +++ b/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/FeaturedSample.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card @@ -51,6 +52,7 @@ public fun FeaturedSample( Column( modifier = modifier + .statusBarsPadding() .padding(16.dp) .fillMaxSize(), verticalArrangement = Arrangement.spacedBy(16.dp), diff --git a/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleApp.kt b/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleApp.kt index 56a9cc0..a91be4c 100644 --- a/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleApp.kt +++ b/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleApp.kt @@ -4,6 +4,22 @@ package dev.androidbroadcast.featured import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import dev.androidbroadcast.featured.registry.FlagRegistry + +/** + * Registers all [SampleFeatureFlags] with [FlagRegistry] so that [FeatureFlagsDebugScreen] + * can discover them via [FlagRegistry.all]. Call once on application start before opening + * the debug UI. Duplicate calls are safe — the registry ignores already-registered params. + */ +public fun registerSampleFlags() { + listOf( + SampleFeatureFlags.mainButtonRed, + SampleFeatureFlags.newFeatureSectionEnabled, + SampleFeatureFlags.newCheckout, + SampleFeatureFlags.promoBannerEnabled, + SampleFeatureFlags.checkoutVariant, + ).forEach(FlagRegistry::register) +} /** * Root composable for the sample application. From 8196bae8779b1ca8dd4b12c8b38bb27ad83ecb88 Mon Sep 17 00:00:00 2001 From: Kirill Rozov Date: Mon, 18 May 2026 14:02:16 +0300 Subject: [PATCH 05/15] Document enum-flag converter requirement (#195) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Gradle DSL accepts enum(...) flag declarations, but at runtime every local storage-backed provider needs an explicit registerConverter call with enumConverter() to handle the enum — DataStore, JavaPreferences, and SharedPreferences all throw IllegalArgumentException otherwise, and NSUserDefaults has no converter API at all. Firebase handles enums automatically via reflection. This was not documented anywhere, so consumers hit a launch crash with no signal in the IDE. Extend the KDoc on FlagContainer.enum, enumConverter, and the plugin README to spell out the requirement and the iOS caveat. Co-authored-by: Claude Opus 4.7 --- .../featured/TypeConverter.kt | 16 ++++++ featured-gradle-plugin/README.md | 54 +++++++++++++++++++ .../featured/gradle/FlagContainer.kt | 45 +++++++++++++++- 3 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 featured-gradle-plugin/README.md diff --git a/core/src/commonMain/kotlin/dev/androidbroadcast/featured/TypeConverter.kt b/core/src/commonMain/kotlin/dev/androidbroadcast/featured/TypeConverter.kt index 7c17063..db3efc5 100644 --- a/core/src/commonMain/kotlin/dev/androidbroadcast/featured/TypeConverter.kt +++ b/core/src/commonMain/kotlin/dev/androidbroadcast/featured/TypeConverter.kt @@ -50,6 +50,22 @@ public interface TypeConverter { * converter.fromString("UNKNOWN") // throws IllegalArgumentException * ``` * + * ## Compatible providers + * + * Pass the returned converter to `registerConverter` on any of these providers before the first + * read or write of an enum flag: + * + * - `DataStoreConfigValueProvider.registerConverter(enumConverter())` + * - `JavaPreferencesConfigValueProvider.registerConverter(enumConverter())` + * - `SharedPreferencesProviderConfig.registerConverter(enumConverter())` + * + * `FirebaseConfigValueProvider` handles enums automatically via reflection — no registration + * is required. + * + * **iOS caveat:** `NSUserDefaultsConfigValueProvider` does not support enums at this time — + * it has no converter API. Use a `String` flag as a workaround on iOS and convert the raw + * value to your enum manually at the call site. + * * @param T The enum class to convert. * @return A [TypeConverter] that round-trips [T] by enum constant name. */ diff --git a/featured-gradle-plugin/README.md b/featured-gradle-plugin/README.md new file mode 100644 index 0000000..e241f7d --- /dev/null +++ b/featured-gradle-plugin/README.md @@ -0,0 +1,54 @@ +# featured-gradle-plugin + +Gradle plugin for the [Featured](../README.md) configuration management library. + +Apply it to a module and declare flags in the `featured { }` DSL block; the plugin generates +typed `ConfigParam` objects, `ConfigValues` extension functions, and R8 shrinker rules. + +## Enum flags + +Declare an enum-typed flag with the `enum(...)` DSL function: + +```kotlin +// build.gradle.kts +featured { + localFlags { + enum( + key = "checkout_variant", + typeFqn = "com.example.CheckoutVariant", + default = "LEGACY", + ) + } +} +``` + +### Runtime converter requirement (Android / JVM) + +Storage-backed local providers serialize values as strings. Before the first read or write of +an enum flag you must register an `enumConverter` on the provider, otherwise the provider +throws `IllegalArgumentException` synchronously. + +Affected providers and the required registration call: + +| Provider | Registration | +|---|---| +| `DataStoreConfigValueProvider` | `provider.registerConverter(enumConverter())` | +| `JavaPreferencesConfigValueProvider` | `provider.registerConverter(enumConverter())` | +| `SharedPreferencesProviderConfig` | `provider.registerConverter(enumConverter())` | + +`FirebaseConfigValueProvider` handles enums automatically via reflection — no registration +is needed. + +```kotlin +// Runtime wiring example (DataStore) +val provider = DataStoreConfigValueProvider(dataStore).apply { + registerConverter(enumConverter()) +} +val configValues = ConfigValues(localProvider = provider) +``` + +### iOS caveat + +`NSUserDefaultsConfigValueProvider` does not support enums at this time — it has no converter +API. Use a `String` flag as a workaround on iOS and convert the raw value to your enum manually +at the call site. diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FlagContainer.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FlagContainer.kt index 5003422..994a516 100644 --- a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FlagContainer.kt +++ b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FlagContainer.kt @@ -84,8 +84,49 @@ public class FlagContainer { /** * Declares an enum-typed feature flag. * - * Enum flags are intentionally excluded from R8 `-assumevalues` DCE rules — the value - * cannot be assumed at build time (it is resolved at runtime from providers). + * The plugin generates a typed `ConfigParam` backed by this declaration. Enum flags are + * intentionally excluded from R8 `-assumevalues` DCE rules — the value cannot be assumed at + * build time (it is resolved at runtime from providers). + * + * ## Runtime converter requirement (Android / JVM) + * + * Storage-backed local providers serialize values as strings and require an explicit + * [enumConverter] registration before the first read or write of this flag. Without it the + * provider throws [IllegalArgumentException] synchronously. Affected providers: + * + * - `DataStoreConfigValueProvider` + * - `JavaPreferencesConfigValueProvider` + * - `SharedPreferencesProviderConfig` + * + * Firebase Remote Config (`FirebaseConfigValueProvider`) handles enums automatically via + * reflection — no `registerConverter` call is needed there. + * + * **iOS caveat:** `NSUserDefaultsConfigValueProvider` does not support enums at this time — + * it has no converter API. Use a `String` flag as a workaround on iOS and convert the raw + * value to your enum manually at the call site. + * + * ## Example + * + * ```kotlin + * // Gradle DSL — declaration + * featured { + * localFlags { + * enum( + * key = "checkout_variant", + * typeFqn = "com.example.CheckoutVariant", + * default = "LEGACY", + * ) + * } + * } + * ``` + * + * ```kotlin + * // Runtime — required wiring for non-Firebase local providers + * val provider = DataStoreConfigValueProvider(dataStore).apply { + * registerConverter(enumConverter()) + * } + * val configValues = ConfigValues(localProvider = provider) + * ``` * * @param key The configuration key string (e.g. `"checkout_variant"`). * @param typeFqn The fully-qualified Kotlin class name of the enum (e.g. `"com.example.CheckoutVariant"`). From 0a1d38b71200eb5fca38de2a788bb026b1e3107f Mon Sep 17 00:00:00 2001 From: Kirill Rozov Date: Mon, 18 May 2026 14:28:31 +0300 Subject: [PATCH 06/15] Migrate user documentation to GitHub Wiki (#193) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace 590-line README with a 75-line landing page that links to the Wiki and showcases a single end-to-end example - Delete mkdocs.yml, docs/{guides,index,getting-started,ios-integration,known-limitations,changelog,api}, and the docs publish workflow — content moved to the Wiki at https://github.com/AndroidBroadcast/Featured/wiki - Keep docs/cc-verification/ and docs/specs/ as internal audit/spec history - Update Swift comments referencing docs/ios-integration.md to point at the new Wiki page - Ignore secring.gpg, .build/, .claude runtime files in .gitignore Co-authored-by: Claude Opus 4.7 --- .github/workflows/docs.yml | 79 ----- .gitignore | 6 + README.md | 568 ++----------------------------- docs/api/index.md | 152 --------- docs/changelog.md | 39 --- docs/getting-started.md | 129 ------- docs/guides/android.md | 300 ---------------- docs/guides/best-practices.md | 303 ----------------- docs/guides/ios.md | 259 -------------- docs/guides/jvm.md | 260 -------------- docs/guides/providers.md | 344 ------------------- docs/guides/r8-verification.md | 78 ----- docs/index.md | 32 -- docs/ios-integration.md | 147 -------- docs/known-limitations.md | 49 --- iosApp/iosApp/ContentView.swift | 2 +- iosApp/iosApp/FeatureFlags.swift | 2 +- mkdocs.yml | 66 ---- 18 files changed, 35 insertions(+), 2780 deletions(-) delete mode 100644 .github/workflows/docs.yml delete mode 100644 docs/api/index.md delete mode 100644 docs/changelog.md delete mode 100644 docs/getting-started.md delete mode 100644 docs/guides/android.md delete mode 100644 docs/guides/best-practices.md delete mode 100644 docs/guides/ios.md delete mode 100644 docs/guides/jvm.md delete mode 100644 docs/guides/providers.md delete mode 100644 docs/guides/r8-verification.md delete mode 100644 docs/index.md delete mode 100644 docs/ios-integration.md delete mode 100644 docs/known-limitations.md delete mode 100644 mkdocs.yml diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml deleted file mode 100644 index 3c0031e..0000000 --- a/.github/workflows/docs.yml +++ /dev/null @@ -1,79 +0,0 @@ -name: Publish Docs - -on: - push: - branches: - - main - - develop - tags: - - "v[0-9]+.[0-9]+.[0-9]+" - - "v[0-9]+.[0-9]+.[0-9]+-*" - pull_request: - branches: - - main - - develop - -permissions: - contents: write - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - build-docs: - name: Build Docs - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v6 - - - uses: ./.github/actions/setup-build-env - - - name: Generate Dokka HTML - run: ./gradlew --no-daemon dokkaGenerate - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.12" - - - name: Install MkDocs Material - run: pip install mkdocs-material==9.5.18 - - - name: Build MkDocs site - run: mkdocs build --strict - - - name: Embed Dokka output into site/api - # Runs after mkdocs build so Dokka HTML does not conflict with - # docs/api/index.md during MkDocs processing. - run: cp -r build/dokka/html/. site/api/ - - - name: Upload site artifact - uses: actions/upload-artifact@v7 - with: - name: docs-site - path: site/ - retention-days: 7 - - publish-docs: - name: Publish to GitHub Pages - runs-on: ubuntu-latest - needs: build-docs - # Only publish on pushes to main or on release tags — not on PRs or develop - if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')) - - steps: - - uses: actions/checkout@v6 - - - name: Download site artifact - uses: actions/download-artifact@v8 - with: - name: docs-site - path: site/ - - - name: Publish to GitHub Pages - uses: peaceiris/actions-gh-pages@v4 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: ./site diff --git a/.gitignore b/.gitignore index 6cadee1..0e32406 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,9 @@ captures /docs/superpowers/ /docs/adr/ /.claude/worktrees/ +/.claude/agent-memory/ +/.claude/scheduled_tasks.lock +/.wiki/ +/.build/ +secring.gpg +*.gpg diff --git a/README.md b/README.md index c59cf15..30c3574 100644 --- a/README.md +++ b/README.md @@ -4,586 +4,72 @@ [![Maven Central](https://img.shields.io/maven-central/v/dev.androidbroadcast.featured/featured-core.svg?label=Maven%20Central)](https://central.sonatype.com/search?q=dev.androidbroadcast.featured) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -**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. +Featured is a type-safe, reactive feature-flag and configuration management library for Kotlin Multiplatform — Android, iOS (via SKIE), and JVM. -## Table of contents +## Highlights -- [Overview](#overview) -- [Installation](#installation) -- [Quick start](#quick-start) -- [Using flags at runtime](#using-flags-at-runtime) -- [Providers](#providers) -- [Debug UI](#debug-ui) -- [Release build optimization](#release-build-optimization) -- [iOS integration](#ios-integration) -- [Multi-module setup](#multi-module-setup) -- [Configuration cache](#configuration-cache) -- [API reference](#api-reference) +- **Type-safe flags** — declared in the Gradle DSL, accessed via generated typed extensions on `ConfigValues`. No string keys, no unchecked casts. +- **Dead-code elimination in release builds** — a flag with `default = false` makes the guarded code unreachable. The Gradle plugin emits R8 `-assumevalues` rules (Android/JVM) and an xcconfig with `DISABLE_` Swift compilation conditions (iOS), so the respective compilers physically strip disabled branches from release binaries. +- **Reactive** — every value is observable via `Flow`; Compose and SwiftUI/Combine integrations included. +- **Multiple providers** — DataStore, SharedPreferences, NSUserDefaults, JavaPreferences, Firebase Remote Config, ConfigCat, or a custom one. +- **Debug UI** — a ready-made Compose screen for overriding flags at runtime. ---- - -## Overview - -**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` | Declares a named, typed configuration key with a default value | -| `ConfigValue` | 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 | - ---- - -## Installation - -### Gradle version catalog - -Add the BOM to manage all module versions from a single place, then declare only the artifacts you need. +## Quick example ```kotlin -// settings.gradle.kts -dependencyResolutionManagement { - repositories { - mavenCentral() - google() - } -} -``` - -```kotlin -// build.gradle.kts (root or app module) +// build.gradle.kts — declare the flag plugins { id("dev.androidbroadcast.featured") version "" } dependencies { implementation(platform("dev.androidbroadcast.featured:featured-bom:")) - - // Core runtime — always required implementation("dev.androidbroadcast.featured: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:featured-datastore-provider") - implementation("dev.androidbroadcast.featured:featured-sharedpreferences-provider") - - // Remote provider - implementation("dev.androidbroadcast.featured:featured-firebase-provider") } -``` - -> The Gradle plugin ID is `dev.androidbroadcast.featured`. It is also published to Maven Central under the artifact `dev.androidbroadcast.featured:featured-gradle-plugin`. - -### iOS — Swift Package Manager - -Add the package in Xcode (**File › Add Package Dependencies**) or in `Package.swift`: - -```swift -.package( - url: "https://github.com/AndroidBroadcast/Featured", - from: "" -) -``` - -Then add `FeaturedCore` as a target dependency: - -```swift -.target( - name: "MyApp", - dependencies: [ - .product(name: "FeaturedCore", package: "Featured") - ] -) -``` ---- - -## Quick start - -### 1. Declare a flag - -Declare flags in `build.gradle.kts` using the `featured { }` DSL block. The plugin generates typed helpers automatically. - -```kotlin title="build.gradle.kts" featured { localFlags { boolean("new_checkout", default = false) { description = "Enable the new checkout flow" - category = "Checkout" - } - int("max_cart_items", default = 10) { - description = "Maximum items allowed in cart" } } } ``` -The plugin generates `internal object GeneratedLocalFlags` with typed `ConfigParam` properties, and public extension functions on `ConfigValues` — for example `fun ConfigValues.isNewCheckoutEnabled(): Boolean` and `fun ConfigValues.getMaxCartItems(): Int`. - -### 2. Create a `ConfigValues` instance - -Wire up providers once, typically in your dependency injection setup or `Application.onCreate`. - ```kotlin -// 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. +// Application.kt — wire up ConfigValues once +val dataStore = PreferenceDataStoreFactory.create { context.dataStoreFile("feature_flags.preferences_pb") } -### 3. Read a flag value - -```kotlin -// Suspend function — call from a coroutine -val value: ConfigValue = configValues.getValue(FeatureFlags.newCheckout) -val isEnabled: Boolean = value.value // the actual value -val source: ConfigValue.Source = value.source // DEFAULT, LOCAL, or REMOTE -``` - ---- - -## Using flags at runtime - -### One-shot read - -```kotlin -val configValue: ConfigValue = configValues.getValue(FeatureFlags.newCheckout) -if (configValue.value) { - // feature is active -} -``` - -### Reactive observation (Flow) - -```kotlin -// 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 = configValues.asStateFlow( - param = FeatureFlags.newCheckout, - scope = viewModelScope, -) -``` - -### Compose extension - -```kotlin -@Composable -fun CheckoutScreen(configValues: ConfigValues) { - val isEnabled: State = configValues.collectAsState(FeatureFlags.newCheckout) - - if (isEnabled.value) { - NewCheckoutContent() - } else { - LegacyCheckoutContent() - } -} -``` - -Use `LocalConfigValues` to provide a `ConfigValues` through the composition tree: - -```kotlin -// 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) - // … -} -``` - -### iOS (Swift) - -The `FeatureFlags` Swift class wraps `CoreConfigValues` (the KMP-exported type). Define your flags as `FeatureFlag` values that reference the shared `CoreConfigParam` exported from Kotlin: - -```swift -import FeaturedCore - -// Map a Kotlin ConfigParam to a Swift FeatureFlag -let newCheckoutFlag = FeatureFlag( - 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) -``` - ---- - -## Providers - -### InMemoryConfigValueProvider (built-in) - -No setup required. Values are stored in memory and lost on process restart. Useful for tests and previews. - -```kotlin -val configValues = ConfigValues( - localProvider = InMemoryConfigValueProvider(), -) -``` - -### DataStoreConfigValueProvider - -Persists overrides to Jetpack DataStore Preferences. - -```kotlin -// Declare once per file, outside any function or class -private val Context.featureFlagsDataStore: DataStore - by preferencesDataStore(name = "feature_flags") - -val configValues = ConfigValues( - localProvider = DataStoreConfigValueProvider(context.featureFlagsDataStore), -) -``` - -### SharedPreferencesProviderConfig - -Android-only. Persists overrides to SharedPreferences. - -```kotlin -val prefs = context.getSharedPreferences("feature_flags", Context.MODE_PRIVATE) - -val configValues = ConfigValues( - localProvider = SharedPreferencesProviderConfig(prefs), -) -``` - -### FirebaseConfigValueProvider (remote) - -Wraps Firebase Remote Config. Remote values override local values. - -```kotlin 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: - ```kotlin -FirebaseConfigValueProvider(remoteConfig = FirebaseRemoteConfig.getInstance()) +// Read the generated extension anywhere +val isEnabled: Boolean = configValues.isNewCheckoutEnabled() ``` -### Override and reset at runtime - -```kotlin -// 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) -``` - ---- - -## Debug UI - -`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. - -### 1. Register flags - -Register each `ConfigParam` in the `FlagRegistry` so the debug screen can discover them: - -```kotlin -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) -``` - -### 2. Show the debug screen - -```kotlin -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): - ---- - -## Release build optimization - -### Android / JVM — R8 rules - -The Gradle plugin generates per-function ProGuard / R8 `-assumevalues` rules for the generated extension functions of every local boolean flag with `default = false`. These rules instruct R8 to treat the flag as a constant `false` at shrink time, so all code guarded by the generated accessor is removed from the release APK. Remote flags are excluded since their values are dynamic. - -The task runs automatically when you build a release variant. To run it manually: - -```bash -./gradlew :app:generateFeaturedProguardRules -``` - -Output: `app/build/featured/proguard-featured.pro` - -No extra configuration is needed — the plugin wires the output into the R8 pipeline automatically. - -### iOS — xcconfig for Swift DCE - -See the [iOS integration](#ios-integration) section below. - ---- - -## iOS integration - -The Gradle plugin generates an xcconfig file that feeds Swift compilation conditions into Xcode. For every local boolean flag declared in `featured { localFlags { } }` with `default = false`, a `DISABLE_` condition is generated. - -### Key transformation - -| Kotlin flag key | Generated condition | -|-----------------|---------------------| -| `new_checkout` | `DISABLE_NEW_CHECKOUT` | -| `experimentalUi` | `DISABLE_EXPERIMENTAL_UI` | - -### Step 1 — Generate the xcconfig - -```bash -./gradlew :shared:generateXcconfig -``` - -Output: `shared/build/featured/FeatureFlags.generated.xcconfig` - -Example content: - -```xcconfig -# Auto-generated by featured-gradle-plugin — do not edit -SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) DISABLE_NEW_CHECKOUT DISABLE_EXPERIMENTAL_UI -``` - -### Step 2 — Make the file available to Xcode - -Copy or symlink the file to a stable path inside your Xcode project tree: - -```bash -# 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.xcconfig -``` - -Add the generated file to `.gitignore` if you use the copy approach: - -```gitignore -iosApp/Configuration/FeatureFlags.generated.xcconfig -``` - -### Step 3 — Configure Xcode (one-time) - -1. Open your `.xcodeproj` in Xcode. -2. Select the project in the Navigator → **Info** tab → **Configurations**. -3. Expand the **Release** configuration. -4. 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. - -### Step 4 — Guard Swift entry points with `#if` - -```swift -// 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() -#endif -``` - -The Swift compiler removes the entire guarded block from Release binaries — zero runtime overhead. - -### Automate with a pre-build Run Script phase - -Add this script to your Xcode target's Build Phases (before Compile Sources). Set **Based on dependency analysis** to **off**: - -```bash -cd "${SRCROOT}/.." -./gradlew :shared:generateXcconfig --quiet -cp shared/build/featured/FeatureFlags.generated.xcconfig \ - iosApp/Configuration/FeatureFlags.generated.xcconfig -``` - ---- - -## Multi-module setup - -In a multi-module project, apply the Gradle plugin to every module that declares flags in `featured { }`. The plugin registers a `resolveFeatureFlags` task per module and an aggregator task `scanAllLocalFlags` at the root that collects flags across all modules. - -```kotlin -// :feature:checkout module build.gradle.kts -plugins { - id("dev.androidbroadcast.featured") - // … other plugins -} -``` - -```kotlin -// :feature:profile module build.gradle.kts -plugins { - id("dev.androidbroadcast.featured") -} -``` - -Run code generation tasks across all modules at once: - -```bash -# Resolve and aggregate flags across all modules -./gradlew scanAllLocalFlags - -# Generate R8 rules for all Android modules -./gradlew generateFeaturedProguardRules - -# Generate xcconfig across all modules -./gradlew generateXcconfig -``` - -Declare 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. - ---- - -## Configuration cache - -`featured-gradle-plugin` officially supports the Gradle [Configuration Cache](https://docs.gradle.org/current/userguide/configuration_cache.html) on **Gradle 9+** and **AGP 9+**. Every task registered by the plugin (`resolveFeatureFlags`, `generateFeaturedProguardRules`, `generateConfigParam`, `generateFlagRegistrar`, `generateIosConstVal`, `generateXcconfig`, `scanAllLocalFlags`) stores and reuses CC entries without violations. - -### Enabling - -Add the following to `gradle.properties`: - -```properties -org.gradle.configuration-cache=true -``` - -### Known gap — AGP 9.x `proguardFiles` provider propagation - -AGP 9.x exposes `variant.proguardFiles` as a `ListProperty`, but on the AGP releases verified during the 1.0.0-Beta cycle (9.1.0) the provider's dependency does **not** propagate to the underlying R8 / minification tasks. As a result, wiring the plugin's generated `proguard-featured.pro` purely through `variant.proguardFiles.add(...)` is insufficient — the R8 task will not see the file as an input dependency and will run before the rules are generated. - -`featured-gradle-plugin` retains a `tasks.configureEach { … }` fallback inside [`AndroidProguardWiring.kt`](featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/AndroidProguardWiring.kt) that explicitly establishes the task dependency. The fallback is CC-safe (no `Project` reference at execution time, no eager configuration). It will be revisited on every AGP minor and removed when the upstream provider propagation gap is fixed. - -Audit artefact: [`docs/cc-verification/agp-propagation-check-2026-05-16.md`](docs/cc-verification/agp-propagation-check-2026-05-16.md). - -### Upstream limitations - -No known upstream Configuration Cache limitations attributable to third-party plugins were observed at time of release across the sample modules (`:sample:android-app`, `:sample:desktop`, `:sample:shared`). - -### Verification artefacts - -All verification artefacts live under `docs/cc-verification/`: - -- [`fixture-report-2026-05-17.md`](docs/cc-verification/fixture-report-2026-05-17.md) — plugin test fixture audit (AC-3). -- [`sample-report-2026-05-17.md`](docs/cc-verification/sample-report-2026-05-17.md) — sample modules audit (AC-4). -- [`agp-propagation-check-2026-05-16.md`](docs/cc-verification/agp-propagation-check-2026-05-16.md) — AGP `proguardFiles` provider propagation audit (AC-5a). - -Isolated-projects support is tracked separately — see [`docs/known-limitations.md`](docs/known-limitations.md). - ---- - -## Running the sample app - -The `sample` module is a Kotlin Multiplatform app (Android + iOS + Desktop) that demonstrates -all provider options available in Featured. - -### Default (DataStore) - -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. - -```bash -./gradlew :sample:assembleDebug -``` - -### SharedPreferences provider - -To see how `SharedPreferencesProviderConfig` is wired up, look at `buildConfigValues()` in -`SampleApplication.kt`. Swap the commented-out `localProvider` assignment for the active one. - -### Running with Firebase Remote Config - -Firebase Remote Config requires a `google-services.json` file from the Firebase console. - -1. Create a Firebase project at [console.firebase.google.com](https://console.firebase.google.com). -2. Register the Android app with package name `dev.androidbroadcast.featured`. -3. Download `google-services.json` and place it at `sample/google-services.json`. -4. Build the sample with the `hasFirebase` flag: - -```bash -./gradlew :sample:assembleDebug -PhasFirebase=true -``` +## Documentation -The build system detects `sample/google-services.json` automatically, so step 4 can also be -run without `-PhasFirebase=true` once the file is present. +Full documentation lives in the [Wiki](https://github.com/AndroidBroadcast/Featured/wiki): -5. In `SampleApplication.kt`, uncomment the `FirebaseConfigValueProvider` lines inside - `buildConfigValues()` and rebuild. +- [Getting Started](https://github.com/AndroidBroadcast/Featured/wiki/Getting-Started) +- [Installation](https://github.com/AndroidBroadcast/Featured/wiki/Installation) +- [Providers](https://github.com/AndroidBroadcast/Featured/wiki/Providers) +- [Release Optimization (DCE)](https://github.com/AndroidBroadcast/Featured/wiki/Release-Optimization) — how flags get stripped from release binaries +- [iOS Usage](https://github.com/AndroidBroadcast/Featured/wiki/iOS-Usage) +- [Best Practices](https://github.com/AndroidBroadcast/Featured/wiki/Best-Practices) -> **Note:** `google-services.json` is excluded from version control (`.gitignore`). Never commit -> credentials to the repository. +## Contributing ---- +See [CONTRIBUTING.md](CONTRIBUTING.md). -## API reference +## Security -Full KDoc-generated API reference is published to GitHub Pages: +See [SECURITY.md](SECURITY.md). -**[https://androidbroadcast.github.io/Featured/](https://androidbroadcast.github.io/Featured/)** +## License -Documentation is regenerated on every merge to `main`. +MIT — see [LICENSE](LICENSE). diff --git a/docs/api/index.md b/docs/api/index.md deleted file mode 100644 index e63d523..0000000 --- a/docs/api/index.md +++ /dev/null @@ -1,152 +0,0 @@ -# API Reference - -The full KDoc-generated API reference is built by [Dokka](https://kotlinlang.org/docs/dokka-introduction.html) and published automatically to GitHub Pages on every release. - -**[Browse the API reference →](https://androidbroadcast.github.io/Featured/api/)** - ---- - -## Core types - -### `ConfigParam` - -Declares a named, typed configuration key with a default value. - -```kotlin -ConfigParam( - key: String, - defaultValue: T, - description: String = "", - category: String = "", -) -``` - -### `ConfigValue` - -Wraps a `ConfigParam` with its resolved value and the source that provided it. - -```kotlin -data class ConfigValue( - val param: ConfigParam, - val value: T, - val source: Source, -) { - enum class Source { DEFAULT, LOCAL, REMOTE } -} -``` - -### `ConfigValues` - -Container that composes local and remote providers and exposes flag values reactively. - -```kotlin -ConfigValues( - localProvider: LocalConfigValueProvider? = null, - remoteProvider: RemoteConfigValueProvider? = null, -) -``` - -At least one provider must be non-null (enforced at construction time). - -**Key methods:** - -| Method | Description | -|--------|-------------| -| `getValue(param)` | Suspend: resolve current value | -| `observe(param)` | `Flow>` — emits on every change | -| `observeValue(param)` | `Flow` — emits raw values only | -| `asStateFlow(param, scope)` | Convert to `StateFlow` | -| `override(param, value)` | Write a local override | -| `resetOverride(param)` | Remove local override | -| `fetch()` | Trigger remote provider fetch and activate | - -### `LocalConfigValueProvider` - -Interface for writable, observable local storage. - -```kotlin -interface LocalConfigValueProvider { - suspend fun getValue(param: ConfigParam): ConfigValue? - fun observe(param: ConfigParam): Flow?> - suspend fun setValue(param: ConfigParam, value: T) - suspend fun removeValue(param: ConfigParam) -} -``` - -### `RemoteConfigValueProvider` - -Interface for fetch-based remote configuration. - -```kotlin -interface RemoteConfigValueProvider { - suspend fun fetch() - suspend fun getValue(param: ConfigParam): ConfigValue? - fun observe(param: ConfigParam): Flow?> -} -``` - ---- - -## Gradle DSL - -Flags are declared in `build.gradle.kts` using the `featured { }` extension block provided by the `dev.androidbroadcast.featured` Gradle plugin. - -```kotlin title="build.gradle.kts" -featured { - localFlags { - boolean("dark_mode", default = false) { category = "UI"; expiresAt = "2026-06-01" } - int("max_retries", default = 3) - } - remoteFlags { - boolean("promo_banner", default = false) { description = "Show promo banner" } - string("api_url", default = "https://api.example.com") - } -} -``` - -### Generated types - -The plugin generates: - -| Generated type | Description | -|---|---| -| `internal object GeneratedLocalFlags` | Typed `ConfigParam` properties for every local flag | -| `internal object GeneratedRemoteFlags` | Typed `ConfigParam` properties for every remote flag | -| Extension functions on `ConfigValues` | Local boolean flag → `fun ConfigValues.isEnabled(): Boolean`; local non-boolean → `fun ConfigValues.get(): T`; remote → `fun ConfigValues.get(): ConfigValue` | - -### Key tasks - -| Task | Description | -|---|---| -| `resolveFeatureFlags` | Resolves DSL-declared flags; runs before all code-generation tasks | -| `generateConfigParam` | Generates `GeneratedLocalFlags` and `GeneratedRemoteFlags` objects | -| `generateFlagRegistrar` | Generates flag registrar for the debug UI | -| `generateFeaturedProguardRules` | Generates per-function R8 `-assumevalues` rules for local boolean flags | -| `generateIosConstVal` | Generates `expect`/`actual const val` for local flags (iOS) | -| `generateXcconfig` | Generates xcconfig with `DISABLE_*` conditions for local boolean flags | -| `scanAllLocalFlags` | Aggregator task — collects flags across all modules | - ---- - -## Compose extensions (featured-compose) - -### `ConfigValues.collectAsState` - -```kotlin -@Composable -fun ConfigValues.collectAsState(param: ConfigParam): State -``` - -Collects the current and future values of `param` as Compose `State`. - -### `LocalConfigValues` - -```kotlin -val LocalConfigValues: ProvidableCompositionLocal -``` - -Composition local for providing a `ConfigValues` instance through the composition tree. - ---- - -The generated Dokka HTML output lives at `build/dokka/htmlMultiModule/` and is deployed to the `api/` path on GitHub Pages. diff --git a/docs/changelog.md b/docs/changelog.md deleted file mode 100644 index e14a179..0000000 --- a/docs/changelog.md +++ /dev/null @@ -1,39 +0,0 @@ -# Changelog - -All notable changes to Featured are documented here. - -For the full release history with diff links, see the -[GitHub Releases page](https://github.com/AndroidBroadcast/Featured/releases). - ---- - -## Unreleased - -_Changes on `main` not yet tagged for release._ - -### Changed -- Renamed the Gradle ProGuard/R8 generation task from `generateProguardRules` to - `generateFeaturedProguardRules` to avoid task-name clashes with consumer scripts. - Migration: update any CI/build scripts that invoke `generateProguardRules` to use - the new name. The old task name is no longer registered. (#190) - ---- - -## Contributing a changelog entry - -When opening a pull request, add a brief entry under **Unreleased** describing your change. -Use one of these categories: - -- **Added** — new public API or feature -- **Changed** — changes to existing behaviour -- **Deprecated** — soon-to-be-removed features -- **Removed** — removed features or APIs -- **Fixed** — bug fixes -- **Security** — vulnerability fixes - -Format: - -```markdown -### Added -- `ConfigValues.newMethod(param)` — short description (#PR) -``` diff --git a/docs/getting-started.md b/docs/getting-started.md deleted file mode 100644 index 9f19e6d..0000000 --- a/docs/getting-started.md +++ /dev/null @@ -1,129 +0,0 @@ -# Getting Started - -This page gets you from zero to a working feature flag in about 5 minutes. - -## Installation - -### Gradle version catalog - -Add the BOM to manage all module versions from a single place, then declare only the artifacts you need. - -```kotlin title="settings.gradle.kts" -dependencyResolutionManagement { - repositories { - mavenCentral() - google() - } -} -``` - -```kotlin title="build.gradle.kts" -plugins { - id("dev.androidbroadcast.featured") version "" -} - -dependencies { - implementation(platform("dev.androidbroadcast.featured:featured-bom:")) - - // Core runtime — always required - implementation("dev.androidbroadcast.featured: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:featured-datastore-provider") - implementation("dev.androidbroadcast.featured:featured-sharedpreferences-provider") - - // Remote provider - implementation("dev.androidbroadcast.featured:featured-firebase-provider") -} -``` - -!!! note - The Gradle plugin ID is `dev.androidbroadcast.featured`. It is also published to Maven Central under the artifact `dev.androidbroadcast.featured:featured-gradle-plugin`. - -### iOS — Swift Package Manager - -Add the package in Xcode (**File › Add Package Dependencies**) or in `Package.swift`: - -```swift -.package( - url: "https://github.com/AndroidBroadcast/Featured", - from: "" -) -``` - -Then add `FeaturedCore` as a target dependency: - -```swift -.target( - name: "MyApp", - dependencies: [ - .product(name: "FeaturedCore", package: "Featured") - ] -) -``` - -## Step 1 — Declare a flag - -Declare flags in `build.gradle.kts` using the `featured { }` DSL block. The plugin generates typed helpers automatically. - -```kotlin title="build.gradle.kts" -featured { - localFlags { - boolean("new_checkout", default = false) { - description = "Enable the new checkout flow" - category = "Checkout" - } - int("max_cart_items", default = 10) { - description = "Maximum items allowed in cart" - } - } - remoteFlags { - boolean("promo_banner", default = false) { - description = "Show promo banner" - } - } -} -``` - -The plugin generates `internal object GeneratedLocalFlags` and `internal object GeneratedRemoteFlags` with typed `ConfigParam` properties, and public extension functions on `ConfigValues` — for example `fun ConfigValues.isNewCheckoutEnabled(): Boolean`, `fun ConfigValues.getMaxCartItems(): Int`, and `fun ConfigValues.getPromoBanner(): ConfigValue`. - -## Step 2 — Create a `ConfigValues` instance - -Wire up providers once, typically in your dependency injection setup or `Application.onCreate`. - -```kotlin title="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. - -## Step 3 — Read a flag value - -Use the generated extension functions on `ConfigValues`: - -```kotlin -// Local boolean flag — returns Boolean directly -val isEnabled: Boolean = configValues.isNewCheckoutEnabled() - -// Local non-boolean flag — returns the value directly -val maxItems: Int = configValues.getMaxCartItems() - -// Remote flag — returns ConfigValue to expose source information -val promo: ConfigValue = configValues.getPromoBanner() -val source: ConfigValue.Source = promo.source // DEFAULT, LOCAL, or REMOTE -``` - -## Next steps - -- [Android guide](guides/android.md) — DataStore, Compose integration, and the debug UI -- [iOS guide](guides/ios.md) — Swift interop and dead-code elimination -- [Providers](guides/providers.md) — all built-in providers in detail -- [Best practices](guides/best-practices.md) — multi-module setup and testing diff --git a/docs/guides/android.md b/docs/guides/android.md deleted file mode 100644 index 001d6b4..0000000 --- a/docs/guides/android.md +++ /dev/null @@ -1,300 +0,0 @@ -# Android Integration Guide - -This guide walks you through integrating Featured into an Android project from scratch — from adding Gradle dependencies to using flags in a ViewModel with Compose and Firebase Remote Config. - -## 1. Add Gradle dependencies - -Apply the Featured Gradle plugin and declare the artifacts you need. The BOM manages all module versions from a single place. - -```kotlin title="build.gradle.kts" -plugins { - id("dev.androidbroadcast.featured") version "" -} - -dependencies { - implementation(platform("dev.androidbroadcast.featured:featured-bom:")) - - // Core runtime — always required - implementation("dev.androidbroadcast.featured:core") - - // Local persistence — pick one (or both) - implementation("dev.androidbroadcast.featured:datastore-provider") - implementation("dev.androidbroadcast.featured:sharedpreferences-provider") - - // Remote config - implementation("dev.androidbroadcast.featured:firebase-provider") - - // Compose extensions - implementation("dev.androidbroadcast.featured:featured-compose") - - // Debug UI — debug builds only - debugImplementation("dev.androidbroadcast.featured:featured-registry") - debugImplementation("dev.androidbroadcast.featured:featured-debug-ui") -} -``` - -!!! note - The Gradle plugin ID is `dev.androidbroadcast.featured`. It generates ProGuard / R8 rules and xcconfig files automatically when you build. - -## 2. Declare flags - -Declare flags in `build.gradle.kts` using the `featured { }` DSL block. The plugin generates typed helpers automatically. - -```kotlin title="build.gradle.kts" -featured { - localFlags { - boolean("new_checkout", default = false) { - description = "Enable the new checkout flow" - } - int("max_cart_items", default = 10) - } -} -``` - -The plugin generates `internal object GeneratedLocalFlags` with typed `ConfigParam` properties and public extension functions on `ConfigValues` such as `fun ConfigValues.isNewCheckoutEnabled(): Boolean` and `fun ConfigValues.getMaxCartItems(): Int`. - -## 3. Initialize `ConfigValues` in `Application.onCreate` - -Create a single `ConfigValues` instance and call `initialize()` before the app serves any screen. `initialize()` triggers the remote provider's activation step (for Firebase: activates fetched values). - -### With DataStore (recommended) - -```kotlin title="MyApplication.kt" -import androidx.datastore.preferences.preferencesDataStore -import dev.androidbroadcast.featured.ConfigValues -import dev.androidbroadcast.featured.datastore.DataStoreConfigValueProvider -import dev.androidbroadcast.featured.firebase.FirebaseConfigValueProvider - -val Context.featureFlagDataStore by preferencesDataStore(name = "feature_flags") - -class MyApplication : Application() { - - lateinit var configValues: ConfigValues - - override fun onCreate() { - super.onCreate() - - val localProvider = DataStoreConfigValueProvider(featureFlagDataStore) - val remoteProvider = FirebaseConfigValueProvider() - - configValues = ConfigValues( - localProvider = localProvider, - remoteProvider = remoteProvider, - ) - - // Activate previously fetched remote values and trigger a background fetch - lifecycleScope.launch { - configValues.initialize() - configValues.fetch() - } - } -} -``` - -### With SharedPreferences - -```kotlin -import android.content.Context -import dev.androidbroadcast.featured.ConfigValues -import dev.androidbroadcast.featured.sharedpreferences.SharedPreferencesProviderConfig - -val prefs = context.getSharedPreferences("feature_flags", Context.MODE_PRIVATE) -val localProvider = SharedPreferencesProviderConfig(prefs) - -val configValues = ConfigValues(localProvider = localProvider) -``` - -## 4. Use in ViewModel with `observe` - -Expose flag state as `StateFlow` so Compose (or view-based UIs) can collect it: - -```kotlin -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import dev.androidbroadcast.featured.ConfigValues -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn - -class CheckoutViewModel( - private val configValues: ConfigValues, -) : ViewModel() { - - // StateFlow of the raw Boolean — reacts to both local and remote changes - val isNewCheckoutEnabled: StateFlow = - configValues.observe(FeatureFlags.newCheckout) - .map { it.value } - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = FeatureFlags.newCheckout.defaultValue, - ) -} -``` - -Or use the built-in `asStateFlow` extension: - -```kotlin -val isNewCheckoutEnabled: StateFlow = configValues.asStateFlow( - param = FeatureFlags.newCheckout, - scope = viewModelScope, -) -``` - -## 5. Compose integration - -Add the `featured-compose` artifact (already listed in step 1). - -### Collecting flag state in a Composable - -```kotlin -@Composable -fun CheckoutScreen(configValues: ConfigValues) { - val isEnabled: State = configValues.collectAsState(FeatureFlags.newCheckout) - - if (isEnabled.value) { - NewCheckoutContent() - } else { - LegacyCheckoutContent() - } -} -``` - -### Providing `ConfigValues` via CompositionLocal - -```kotlin -// 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) - // … -} -``` - -## 6. Add Firebase Remote Config provider - -`FirebaseConfigValueProvider` wraps the Firebase SDK. Add the dependency (step 1) and pass it as `remoteProvider`: - -```kotlin -import com.google.firebase.remoteconfig.FirebaseRemoteConfig -import dev.androidbroadcast.featured.firebase.FirebaseConfigValueProvider - -// Use the default singleton (recommended) -val remoteProvider = FirebaseConfigValueProvider() - -// Or supply a custom instance with non-default fetch interval -val remoteConfig = FirebaseRemoteConfig.getInstance().also { config -> - config.setConfigSettingsAsync( - com.google.firebase.remoteconfig.remoteConfigSettings { - minimumFetchIntervalInSeconds = 3600 - } - ) -} -val remoteProvider = FirebaseConfigValueProvider(remoteConfig) - -val configValues = ConfigValues( - localProvider = localProvider, - remoteProvider = remoteProvider, -) -``` - -After `configValues.initialize()`, remote values fetched during the previous session become active. Call `configValues.fetch()` to trigger a fresh fetch and activate the result. - -## 7. Debug UI — flag override screen - -`featured-debug-ui` ships a Compose screen that lists every registered flag with its current value and source, and lets you toggle or override values at runtime. - -Add debug artifacts only to debug builds (step 1 already shows `debugImplementation`). - -### Register flags - -```kotlin -import dev.androidbroadcast.featured.registry.FlagRegistry - -// Call once in Application.onCreate (or your DI module) — debug builds only -if (BuildConfig.DEBUG) { - FlagRegistry.register(FeatureFlags.newCheckout) - FlagRegistry.register(FeatureFlags.maxCartItems) -} -``` - -### Show the debug screen - -```kotlin -import dev.androidbroadcast.featured.debugui.FeatureFlagsDebugScreen - -@Composable -fun DebugMenuScreen(configValues: ConfigValues) { - FeatureFlagsDebugScreen(configValues = configValues) -} -``` - -Navigate to this screen from your in-app debug menu (a drawer, a shake gesture, or a long-press on the app icon shortcut). - -## 8. ProGuard / R8 setup - -The Gradle plugin generates per-function `-assumevalues` rules for the generated extension functions of every local boolean flag with `default = false`. These rules instruct R8 to treat the flag as a compile-time constant `false`, removing all guarded code from release APKs. Remote flags are excluded since their values are dynamic. - -The task runs automatically when you build a release variant. To run it manually: - -```bash -./gradlew :app:generateFeaturedProguardRules -``` - -Output: `app/build/featured/proguard-featured.pro` - -No extra configuration is needed — the plugin wires the output into the R8 pipeline automatically. - -## Overriding and resetting at runtime - -```kotlin -// 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) - -// Clear all local overrides -configValues.clearOverrides() -``` - -## Reading flags - -### One-shot read - -```kotlin -val configValue: ConfigValue = configValues.getValue(FeatureFlags.newCheckout) -if (configValue.value) { - // feature is active -} -val source = configValue.source // DEFAULT, LOCAL, or REMOTE -``` - -### Reactive observation (Flow) - -```kotlin -// 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.observe(FeatureFlags.newCheckout) - .map { it.value } - .collect { isEnabled: Boolean -> /* … */ } -``` - -## Next steps - -- [iOS guide](ios.md) — Swift interop and dead-code elimination -- [JVM guide](jvm.md) — server and desktop integration -- [Providers](providers.md) — all built-in providers in detail -- [Best practices](best-practices.md) — multi-module setup and testing diff --git a/docs/guides/best-practices.md b/docs/guides/best-practices.md deleted file mode 100644 index 5c60b97..0000000 --- a/docs/guides/best-practices.md +++ /dev/null @@ -1,303 +0,0 @@ -# Best Practices - -## Flag lifecycle - -Feature flags are temporary by design. Every flag should progress through three stages and then be deleted. - -``` -Draft → Rollout → Cleanup -``` - -### 1. Draft — introduce the flag - -Declare the flag in `build.gradle.kts` using the `featured { }` DSL block and set an expiry date using `expiresAt` so stale flags surface automatically in CI. Use `localFlags { }` for flags that will be deleted once the rollout is complete, and `remoteFlags { }` for flags that will be permanently controlled from the server. - -```kotlin -// GOOD: expiry date set, snake_case key, module-prefixed -featured { - localFlags { - boolean("checkout_new_flow", default = false) { - description = "Enable the redesigned single-page checkout" - category = "checkout" - expiresAt = "2026-09-01" - } - } -} -``` - -Guard every entry point — UI composition roots **and** deep-link handlers — behind the generated extension function. Keep the default `false` so the feature is off until you explicitly enable it. - -```kotlin -// Guard a Compose entry point -val isNewCheckout by configValues.collectAsState(GeneratedLocalFlags.checkoutNewFlow) - -if (isNewCheckout) { - NewCheckoutScreen() -} else { - LegacyCheckoutScreen() -} -``` - -### 2. Rollout — enable remotely - -Use a `RemoteConfigValueProvider` (e.g. Firebase Remote Config) to enable the flag for a growing percentage of users. Remote values automatically override local defaults — no code change required. - -Declare the flag in `remoteFlags { }` when it is intended to be permanently controlled from the server (A/B experiments, promotional banners). Use `localFlags { }` for flags that will eventually be deleted once the rollout is complete. - -```kotlin -// Permanent remote-controlled flag -featured { - remoteFlags { - boolean("promo_banner_enabled", default = false) { - description = "Show a promotional banner (remote-controlled)" - category = "promotions" - } - } -} -``` - -### 3. Cleanup — delete the flag - -Once the feature is fully rolled out and validated: - -1. Remove the flag from the `featured { }` DSL block in `build.gradle.kts`. -2. Delete all usages of the generated extension function and any guarding `if` blocks — keep only the new-path code. -3. Remove the corresponding key from your remote configuration backend. -4. Regenerate platform artefacts: - -```bash -./gradlew generateFeaturedProguardRules # keep Android R8 rules in sync -./gradlew generateXcconfig # keep iOS xcconfig in sync -``` - -The `ExpiredFeatureFlagRule` Detekt rule will warn at build time for any flag whose `@ExpiresAt` date has passed, preventing flags from silently accumulating. - ---- - -## Naming conventions - -| What | Convention | Example | -|---|---|---| -| `ConfigParam` key | `snake_case`, module-prefixed | `checkout_new_flow` | -| Kotlin property | `camelCase` matching the key | `newCheckoutFlow` | -| Firebase / remote key | Same `snake_case` as the key | `checkout_new_flow` | - -Group related flags with a shared prefix (`checkout_*`, `payments_*`). This keeps the debug UI readable and makes it obvious which team owns each flag. - -```kotlin -// GOOD -val newCheckoutFlow: ConfigParam = ConfigParam(key = "checkout_new_flow", ...) -val checkoutPaymentV2: ConfigParam = ConfigParam(key = "checkout_payment_v2", ...) - -// BAD — no module prefix, impossible to attribute ownership -val enabled: ConfigParam = ConfigParam(key = "new_flow", ...) -``` - ---- - -## Multi-module patterns - -### One FlagRegistry per module - -Each feature module declares its own object holding its `ConfigParam` instances. The module does not create `ConfigValues` — that is the app module's responsibility. - -```kotlin title=":feature:checkout/build.gradle.kts" -featured { - localFlags { - boolean("checkout_new_flow", default = false) { - description = "Enable the redesigned checkout flow" - category = "checkout" - expiresAt = "2026-09-01" - } - boolean("checkout_payment_v2", default = false) { - description = "Enable Payment V2 during checkout" - category = "checkout" - expiresAt = "2026-09-01" - } - } -} -``` - -```kotlin title=":feature:promotions/build.gradle.kts" -featured { - remoteFlags { - boolean("promo_banner_enabled", default = false) { - description = "Show a promotional banner (remote-controlled)" - category = "promotions" - } - } -} -``` - -### App-level aggregation - -The app module owns the single `ConfigValues` instance and wires together providers. Feature modules receive `ConfigValues` via dependency injection. - -```kotlin title=":app/src/main/kotlin/.../AppModule.kt (Hilt example)" -@Module -@InstallIn(SingletonComponent::class) -object AppModule { - - @Provides - @Singleton - fun provideConfigValues( - @ApplicationContext context: Context, - ): ConfigValues = ConfigValues( - localProvider = DataStoreConfigValueProvider(context.featureFlagsDataStore), - remoteProvider = FirebaseConfigValueProvider(), - ) -} -``` - -Feature modules consume `ConfigValues` without knowing how providers are wired: - -```kotlin title=":feature:checkout/src/.../CheckoutViewModel.kt" -@HiltViewModel -class CheckoutViewModel @Inject constructor( - private val configValues: ConfigValues, -) : ViewModel() { - - // isCheckoutNewFlowEnabled() is the generated extension function from the DSL declaration - val isNewFlowEnabled: StateFlow = configValues.asStateFlow( - param = GeneratedLocalFlags.checkoutNewFlow, - scope = viewModelScope, - ) -} -``` - - ---- - -## Testing - -Use `fakeConfigValues` from the `featured-testing` artifact — it is fully synchronous, has no external dependencies, and supports both initial values and mid-test overrides. - -```kotlin -import dev.androidbroadcast.featured.testing.fakeConfigValues - -class CheckoutViewModelTest { - - @Test - fun `new checkout flow enabled shows new UI`() = runTest { - val configValues = fakeConfigValues { - set(CheckoutFlags.newFlow, true) - } - val vm = CheckoutViewModel(configValues) - - assertEquals(true, vm.isNewFlowEnabled.value) - } - - @Test - fun `new checkout flow disabled shows legacy UI`() = runTest { - // No override — default value (false) applies - val configValues = fakeConfigValues() - val vm = CheckoutViewModel(configValues) - - assertEquals(false, vm.isNewFlowEnabled.value) - } - - @Test - fun `reactive update when flag toggled mid-test`() = runTest { - val configValues = fakeConfigValues { - set(CheckoutFlags.newFlow, false) - } - val vm = CheckoutViewModel(configValues) - - // Simulate a remote change arriving during the session - configValues.override(CheckoutFlags.newFlow, true) - - assertEquals(true, vm.isNewFlowEnabled.value) - } -} -``` - -Never use real providers (`FirebaseConfigValueProvider`, `DataStoreConfigValueProvider`) in unit tests — they require Android or network context and make tests non-deterministic. - ---- - -## Anti-patterns - -### Flags that never get cleaned up - -Flags are temporary scaffolding, not permanent configuration. Without an expiry date they accumulate silently. - -```kotlin -// BAD — no expiresAt, will never prompt cleanup -featured { - localFlags { - boolean("checkout_new_flow", default = false) - } -} - -// GOOD — expiresAt triggers the ExpiredFeatureFlagRule Detekt warning on the deadline -featured { - localFlags { - boolean("checkout_new_flow", default = false) { - expiresAt = "2026-09-01" - } - } -} -``` - -### Using flags for configuration values - -`localFlags` boolean entries are for feature toggles that will be deleted. Long-lived configuration values (thresholds, URLs, strings) should be declared in `remoteFlags { }` — they are not subject to the cleanup lifecycle. - -```kotlin -// BAD — a URL is not a temporary feature flag; it will never be "cleaned up" -featured { - localFlags { - string("api_base_url", default = "https://api.example.com") - } -} - -// GOOD — permanent remote config value, not a temporary flag -featured { - remoteFlags { - string("api_base_url", default = "https://api.example.com") - } -} -``` - -### Hardcoding flag values in production code - -Hardcoding `true` or `false` instead of reading from `ConfigValues` defeats the purpose of the system. The `HardcodedFlagValueRule` Detekt rule catches direct accesses to `ConfigParam.defaultValue` in production code. - -```kotlin -// BAD — bypasses the provider stack entirely -if (FeatureFlags.newCheckout.defaultValue) { ... } - -// GOOD — reads the live value through ConfigValues -if (configValues.getValue(FeatureFlags.newCheckout)) { ... } -``` - -### Testing with real providers - -```kotlin -// BAD — requires Firebase SDK and network; non-deterministic -val configValues = ConfigValues(remoteProvider = FirebaseConfigValueProvider()) - -// GOOD — deterministic, no dependencies -val configValues = fakeConfigValues { set(CheckoutFlags.newFlow, true) } -``` - ---- - -## Automated enforcement (Detekt rules) - -Add the `featured-detekt-rules` dependency to your Detekt configuration to enforce the above patterns automatically at build time: - -| Rule | What it catches | -|---|---| -| `ExpiredFeatureFlagRule` | Flags whose `expiresAt` date is in the past | -| `HardcodedFlagValueRule` | Direct access to `ConfigParam.defaultValue` in production code | - -With these rules enabled, the lifecycle contract is enforced by CI rather than code review alone. - ---- - -## Security - -- Never store secrets (API keys, tokens) as `ConfigParam` values. Flags are for feature toggles and configuration, not credentials. -- Remote Config values are not end-to-end encrypted. Do not use them to gate security-critical behaviour. -- Default values are compiled into the binary. Do not rely on a flag's default being secret. diff --git a/docs/guides/ios.md b/docs/guides/ios.md deleted file mode 100644 index ca05363..0000000 --- a/docs/guides/ios.md +++ /dev/null @@ -1,259 +0,0 @@ -# iOS Integration Guide - -Featured exposes its Kotlin API to Swift via [SKIE](https://skie.touchlab.co/), which bridges coroutines, sealed classes, and default arguments automatically. - -## 1. Swift Package Manager setup - -Add the package in Xcode (**File › Add Package Dependencies**) or in `Package.swift`: - -```swift -.package( - url: "https://github.com/AndroidBroadcast/Featured", - from: "" -) -``` - -Then add `FeaturedCore` as a target dependency: - -```swift -.target( - name: "MyApp", - dependencies: [ - .product(name: "FeaturedCore", package: "Featured") - ] -) -``` - -## 2. Declare flags in the shared module - -Declare flags in the shared module's `build.gradle.kts` using the `featured { }` DSL block. The plugin generates typed helpers automatically. - -```kotlin title="shared/build.gradle.kts" -featured { - localFlags { - boolean("new_checkout", default = false) { - description = "Enable the new checkout flow" - } - } -} -``` - -The plugin generates `internal object GeneratedLocalFlags` with typed `ConfigParam` properties and public extension functions on `ConfigValues` such as `fun ConfigValues.isNewCheckoutEnabled(): Boolean`. These generated types are exported to Swift via the KMP framework. - -## 3. Initialize `ConfigValues` in Swift - -Call `initialize()` at app launch (before serving any screen) to activate values fetched during the previous session. Then trigger a background fetch so the next launch sees fresh values. - -```swift -import FeaturedCore - -@main -struct MyApp: App { - @StateObject private var appState = AppState() - - var body: some Scene { - WindowGroup { - ContentView() - .task { await appState.setup() } - } - } -} - -@MainActor -class AppState: ObservableObject { - let configValues: ConfigValues - - init() { - configValues = ConfigValues( - localProvider: nil, // add a provider if needed - remoteProvider: nil, // e.g. FirebaseConfigValueProvider() - onProviderError: { error in print("Featured error: \(error)") } - ) - } - - func setup() async { - do { - try await configValues.initialize() - try await configValues.fetch() - } catch { - print("Featured setup error: \(error)") - } - } -} -``` - -## 4. Reading flags in Swift - -The SKIE bridge makes the Kotlin `ConfigValues` API available in Swift with async/await and `AsyncStream`. - -```swift -import FeaturedCore - -// One-shot async read -let configValue = try await configValues.getValue(param: FeatureFlags.shared.newCheckout) -let isEnabled: Bool = configValue.value - -// AsyncStream — use in a Task or async for-await loop -for await configValue in configValues.observe(param: FeatureFlags.shared.newCheckout) { - updateUI(configValue.value) -} -``` - -## 5. Combine publisher - -SKIE wraps the Kotlin `Flow` as an `AsyncStream`. Combine publishers can be built on top using `AsyncStream.publisher`: - -```swift -import Combine -import FeaturedCore - -class CheckoutViewModel: ObservableObject { - @Published var isNewCheckoutEnabled: Bool = false - - private var cancellables = Set() - private let configValues: ConfigValues - - init(configValues: ConfigValues) { - self.configValues = configValues - } - - func startObserving() { - // Bridge AsyncStream → Combine publisher - let stream = configValues.observe(param: FeatureFlags.shared.newCheckout) - - Task { - for await configValue in stream { - await MainActor.run { - self.isNewCheckoutEnabled = configValue.value - } - } - } - } -} -``` - -Alternatively, use `publisher(for:)` if your Swift wrapper exposes it: - -```swift -featureFlags.publisher(for: newCheckoutFlag) - .receive(on: DispatchQueue.main) - .sink { isEnabled in updateUI(isEnabled) } - .store(in: &cancellables) -``` - -## 6. SwiftUI integration - -Collect the flag value in a `@StateObject` ViewModel and bind it to the view: - -```swift -struct CheckoutScreen: View { - @StateObject private var viewModel: CheckoutViewModel - - var body: some View { - Group { - if viewModel.isNewCheckoutEnabled { - NewCheckoutView() - } else { - LegacyCheckoutView() - } - } - .task { viewModel.startObserving() } - } -} -``` - -## 7. Swift dead-code elimination via xcconfig - -The Gradle plugin generates an xcconfig file that feeds Swift compilation conditions into Xcode. For every local boolean flag declared in `featured { localFlags { } }` with `default = false`, a `DISABLE_` condition is generated. - -### Key transformation - -| Kotlin flag key | Generated condition | -|-------------------|--------------------------| -| `new_checkout` | `DISABLE_NEW_CHECKOUT` | -| `experimentalUi` | `DISABLE_EXPERIMENTAL_UI`| - -### Step 1 — Generate the xcconfig - -```bash -./gradlew :shared:generateXcconfig -``` - -Output: `shared/build/featured/FeatureFlags.generated.xcconfig` - -Example content: - -```xcconfig -# Auto-generated by featured-gradle-plugin — do not edit -SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) DISABLE_NEW_CHECKOUT DISABLE_EXPERIMENTAL_UI -``` - -### Step 2 — Make the file available to Xcode - -Copy or symlink the file to a stable path inside your Xcode project tree: - -```bash -# 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.xcconfig -``` - -Add the generated file to `.gitignore` if you use the copy approach: - -```gitignore -iosApp/Configuration/FeatureFlags.generated.xcconfig -``` - -### Step 3 — Configure Xcode (one-time) - -1. Open your `.xcodeproj` in Xcode. -2. Select the project in the Navigator → **Info** tab → **Configurations**. -3. Expand the **Release** configuration. -4. Set the configuration file for your app target to `FeatureFlags.generated.xcconfig`. - -!!! tip - Only assign the xcconfig to **Release**. Debug builds intentionally omit it so every feature remains reachable during development. - -### Step 4 — Guard Swift entry points with `#if` - -```swift -// 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() -#endif -``` - -The Swift compiler removes the entire guarded block from Release binaries — zero runtime overhead. - -### Automate with a pre-build Run Script phase - -Add this script to your Xcode target's Build Phases (before Compile Sources). Set **Based on dependency analysis** to **off**: - -```bash -cd "${SRCROOT}/.." -./gradlew :shared:generateXcconfig --quiet -cp shared/build/featured/FeatureFlags.generated.xcconfig \ - iosApp/Configuration/FeatureFlags.generated.xcconfig -``` - -For more detail, see the full [iOS Integration Guide](../ios-integration.md). - -## Next steps - -- [Android guide](android.md) — DataStore, Compose integration, and the debug UI -- [JVM guide](jvm.md) — server and desktop integration -- [Providers](providers.md) — all built-in providers in detail diff --git a/docs/guides/jvm.md b/docs/guides/jvm.md deleted file mode 100644 index c1aaaa8..0000000 --- a/docs/guides/jvm.md +++ /dev/null @@ -1,260 +0,0 @@ -# JVM / Desktop Integration Guide - -Featured works on plain JVM targets (server, desktop, CLI) without any Android or iOS dependencies. - -## 1. Add Gradle dependencies - -```kotlin title="build.gradle.kts" -plugins { - id("dev.androidbroadcast.featured") version "" -} - -dependencies { - implementation(platform("dev.androidbroadcast.featured:featured-bom:")) - - // Core runtime — always required - implementation("dev.androidbroadcast.featured:core") - - // Persistent local provider backed by java.util.prefs.Preferences - implementation("dev.androidbroadcast.featured:javaprefs-provider") - - // Test helper — add to test scope only - testImplementation("dev.androidbroadcast.featured:featured-testing") -} -``` - -!!! note - The `javaprefs-provider` artifact is JVM-only. It does not pull in any Android or Apple platform dependencies. - -## 2. Declare flags - -Declare flags in `build.gradle.kts` using the `featured { }` DSL block. The plugin generates typed helpers automatically. - -```kotlin title="build.gradle.kts" -featured { - localFlags { - boolean("dark_mode", default = false) { - description = "Enable dark mode UI" - } - int("page_size", default = 20) { - description = "Number of items per page" - } - } -} -``` - -The plugin generates `internal object GeneratedLocalFlags` with typed `ConfigParam` properties and public extension functions on `ConfigValues` such as `fun ConfigValues.isDarkModeEnabled(): Boolean` and `fun ConfigValues.getPageSize(): Int`. - -## 3. Create `ConfigValues` with `JavaPreferencesConfigValueProvider` - -`JavaPreferencesConfigValueProvider` persists values using `java.util.prefs.Preferences`. Storage is OS-specific: the registry on Windows, a plist on macOS, and `~/.java` on Linux. Values survive process restarts automatically. - -```kotlin -import dev.androidbroadcast.featured.ConfigValues -import dev.androidbroadcast.featured.javaprefs.JavaPreferencesConfigValueProvider -import java.util.prefs.Preferences - -// Default: stores under the user root, node "featured" -val provider = JavaPreferencesConfigValueProvider() - -// Custom node — useful for isolating test data or multiple app instances -val provider = JavaPreferencesConfigValueProvider( - node = Preferences.userRoot().node("com/example/myapp/flags") -) - -val configValues = ConfigValues(localProvider = provider) -``` - -### Supporting custom types - -Built-in support covers `String`, `Int`, `Boolean`, `Float`, `Long`, and `Double`. Register a `TypeConverter` for any additional type before first use: - -```kotlin -import dev.androidbroadcast.featured.TypeConverter -import dev.androidbroadcast.featured.javaprefs.registerConverter - -enum class Theme { LIGHT, DARK, SYSTEM } - -provider.registerConverter( - TypeConverter( - fromString = { Theme.valueOf(it) }, - toString = { it.name }, - ) -) -``` - -## 4. Initialize and fetch (optional) - -On JVM there is typically no remote provider, so `initialize()` and `fetch()` are not required. If you wire a remote provider (e.g., a custom `RemoteConfigValueProvider` backed by a feature-flag service), call them once on startup: - -```kotlin -import kotlinx.coroutines.runBlocking - -runBlocking { - configValues.initialize() - configValues.fetch() -} -``` - -## 5. Read flags - -```kotlin -import kotlinx.coroutines.runBlocking - -// One-shot read — from a coroutine or runBlocking in scripts / tests -val value = runBlocking { configValues.getValue(FeatureFlags.darkMode) } -println("dark_mode = ${value.value} (source: ${value.source})") -// source is DEFAULT, LOCAL, or REMOTE -``` - -## 6. Reactive observation - -Featured uses Kotlin Coroutines' `Flow` for reactive updates on all platforms, including JVM: - -```kotlin -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.runBlocking - -runBlocking { - configValues.observe(FeatureFlags.darkMode).collect { configValue -> - println("dark_mode changed: ${configValue.value}") - } -} -``` - -In a long-lived server process, collect inside a `CoroutineScope` tied to the application lifecycle: - -```kotlin -applicationScope.launch { - configValues.observe(FeatureFlags.darkMode).collect { configValue -> - // Reconfigure the application when the flag changes - updateTheme(configValue.value) - } -} -``` - -## 7. Override and reset at runtime - -```kotlin -// Apply a local override — useful for admin overrides or staged rollouts -configValues.override(FeatureFlags.darkMode, true) - -// Reset to the stored or default value -configValues.resetOverride(FeatureFlags.darkMode) - -// Clear all local overrides -configValues.clearOverrides() -``` - -## 8. Testing with `FakeConfigValues` - -The `featured-testing` artifact provides `fakeConfigValues` — a suspend factory function that builds a `ConfigValues` backed by an in-memory provider. No real `Preferences` storage is involved. - -```kotlin -import dev.androidbroadcast.featured.testing.fakeConfigValues -import dev.androidbroadcast.featured.testing.fake -import kotlinx.coroutines.test.runTest - -class FeatureFlagTest { - - @Test - fun `new checkout is enabled when flag is on`() = runTest { - val configValues = fakeConfigValues { - set(FeatureFlags.newCheckout, true) - } - - val value = configValues.getValue(FeatureFlags.newCheckout) - assertTrue(value.value) - } - - @Test - fun `defaults are used when no override is set`() = runTest { - val configValues = fakeConfigValues() - - val value = configValues.getValue(FeatureFlags.darkMode) - assertEquals(FeatureFlags.darkMode.defaultValue, value.value) - } -} -``` - -You can also use the companion extension for a more idiomatic call site: - -```kotlin -import dev.androidbroadcast.featured.ConfigValues -import dev.androidbroadcast.featured.testing.fake - -val configValues = ConfigValues.fake { - set(FeatureFlags.pageSize, 50) -} -``` - -### Simulating mid-test flag changes - -`fakeConfigValues` returns a real `ConfigValues` instance — you can call `override` to simulate remote pushes or user-triggered overrides: - -```kotlin -@Test -fun `UI updates when flag changes at runtime`() = runTest { - val configValues = fakeConfigValues { - set(FeatureFlags.newCheckout, false) - } - - val collected = mutableListOf() - val job = launch { - configValues.observe(FeatureFlags.newCheckout).collect { collected.add(it.value) } - } - - configValues.override(FeatureFlags.newCheckout, true) - advanceUntilIdle() - - job.cancel() - assertEquals(listOf(false, true), collected) -} -``` - -## 9. Writing a custom provider - -Implement `LocalConfigValueProvider` to back flags with any storage (database, config file, etc.): - -```kotlin -import dev.androidbroadcast.featured.ConfigParam -import dev.androidbroadcast.featured.ConfigValue -import dev.androidbroadcast.featured.LocalConfigValueProvider -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow - -class PropertiesConfigValueProvider( - private val file: java.io.File, -) : LocalConfigValueProvider { - - private val props = java.util.Properties().also { - if (file.exists()) it.load(file.reader()) - } - - override suspend fun get(param: ConfigParam): ConfigValue? { - val raw = props.getProperty(param.key) ?: return null - @Suppress("UNCHECKED_CAST") - val value = raw as? T ?: return null - return ConfigValue(value, ConfigValue.Source.LOCAL) - } - - override fun observe(param: ConfigParam): Flow> = - MutableStateFlow(null) // simplified — add file watching for full reactivity - - override suspend fun set(param: ConfigParam, value: T) { - props.setProperty(param.key, value.toString()) - props.store(file.writer(), null) - } - - override suspend fun resetOverride(param: ConfigParam) { - props.remove(param.key) - props.store(file.writer(), null) - } -} -``` - -## Next steps - -- [Providers](providers.md) — all built-in providers in detail -- [Best practices](best-practices.md) — multi-module setup and testing -- [Android guide](android.md) — DataStore, Compose integration, and the debug UI diff --git a/docs/guides/providers.md b/docs/guides/providers.md deleted file mode 100644 index 12ce19b..0000000 --- a/docs/guides/providers.md +++ /dev/null @@ -1,344 +0,0 @@ -# Providers - -`ConfigValues` composes one optional local provider and one optional remote provider. At least one must be provided. - -``` -ConfigValues -├── LocalConfigValueProvider (optional, but at least one required) -└── RemoteConfigValueProvider (optional, but at least one required) -``` - -Remote values take precedence over local values when both are present for the same key. - ---- - -## Built-in local providers - -### InMemoryConfigValueProvider - -Stores overrides in a plain in-memory `Map`. No setup, no dependencies. - -**Use cases:** unit tests, Compose previews, ephemeral runtime overrides that do not need to survive process death. - -**Limitations:** values are lost when the process terminates. Not suitable for user-facing feature flag overrides that must persist across app restarts. - -```kotlin -val configValues = ConfigValues( - localProvider = InMemoryConfigValueProvider(), -) -``` - -Override and reset a value programmatically: - -```kotlin -val provider = InMemoryConfigValueProvider() -val configValues = ConfigValues(localProvider = provider) - -provider.set(DarkModeParam, true) // override -provider.resetOverride(DarkModeParam) // revert to default/remote -provider.clear() // remove all overrides (no Flow signal emitted) -``` - -`set` and `resetOverride` notify active `observe` flows immediately. `clear` does not emit change signals — use `resetOverride` per-param when reactive teardown is needed. - ---- - -### DataStoreConfigValueProvider - -Persists overrides to [Jetpack DataStore Preferences](https://developer.android.com/topic/libraries/architecture/datastore). Reactive: changes emit immediately via `Flow` without polling. - -**Supported types natively:** `String`, `Int`, `Long`, `Float`, `Double`, `Boolean`. - -**Custom types** (e.g. enums) require a registered `TypeConverter` — see [Custom types](#custom-types) below. - -**Dependency:** - -```kotlin -implementation("dev.androidbroadcast.featured:datastore-provider") -``` - -**Setup:** - -```kotlin -// Declare once per file, outside any function or class -private val Context.featureFlagsDataStore: DataStore - by preferencesDataStore(name = "feature_flags") - -val provider = DataStoreConfigValueProvider(context.featureFlagsDataStore) -val configValues = ConfigValues(localProvider = provider) -``` - -**Custom type (enum) example:** - -```kotlin -enum class CheckoutVariant { STANDARD, ONE_CLICK } - -provider.registerConverter(enumConverter()) -``` - -`registerConverter` must be called before the first `get` or `set` call for that type. - -**Persistence behaviour:** writes are performed via `DataStore.edit`, which is atomic and crash-safe. Active `observe` flows re-emit after each write. `clear()` removes all keys from the DataStore file and also causes observers to re-emit. - ---- - -### SharedPreferencesConfigValueProvider - -Android-only. Persists overrides to `SharedPreferences`. All reads and writes are dispatched on `Dispatchers.IO`. - -**Supported types:** `String`, `Int`, `Long`, `Float`, `Double`, `Boolean`. - -**Dependency:** - -```kotlin -implementation("dev.androidbroadcast.featured:sharedpreferences-provider") -``` - -**Setup:** - -```kotlin -val prefs = context.getSharedPreferences("feature_flags", Context.MODE_PRIVATE) - -val provider = SharedPreferencesProviderConfig(prefs) -val configValues = ConfigValues(localProvider = provider) -``` - -**Custom type (enum) example:** - -```kotlin -provider.registerConverter(enumConverter()) -``` - -**Additional context:** you can merge an extra `CoroutineContext` into the IO dispatcher used for all operations: - -```kotlin -val provider = SharedPreferencesProviderConfig(prefs, additionalContext = myContext) -``` - -Active `observe` flows receive updates on every `set`, `resetOverride`, or `remove` call for the observed key. Consecutive identical values are deduplicated via `distinctUntilChanged`. - -!!! note - Prefer `DataStoreConfigValueProvider` for new projects. `SharedPreferencesProviderConfig` exists for projects that already rely on `SharedPreferences` and want to avoid a migration. - ---- - -### NSUserDefaultsConfigValueProvider - -iOS-only. Persists overrides to [`NSUserDefaults`](https://developer.apple.com/documentation/foundation/nsuserdefaults). - -**Supported types:** `String`, `Int`, `Long`, `Float`, `Double`, `Boolean`. - -**Dependency:** - -```kotlin -implementation("dev.androidbroadcast.featured:nsuserdefaults-provider") -``` - -**Setup:** - -```kotlin -// Uses the standard user defaults -val provider = NSUserDefaultsConfigValueProvider() - -// Or use a named suite (recommended for app groups / extensions) -val provider = NSUserDefaultsConfigValueProvider(suiteName = "com.example.app.flags") - -val configValues = ConfigValues(localProvider = provider) -``` - -Active `observe` flows receive updates on every `set` or `resetOverride` call. `clear()` removes all keys but does **not** emit change signals to observers — call `resetOverride` per param when reactive teardown is required. - -!!! note - `NSUserDefaults` returns a default value (0, `false`, `""`) when a key is absent. The provider checks `objectForKey` first to correctly distinguish "not set" from "set to the zero value". - ---- - -### JavaPreferencesConfigValueProvider - -JVM-only. Persists overrides using [`java.util.prefs.Preferences`](https://docs.oracle.com/en/java/docs/books/tutorial/essential/environment/prefs.html). Storage is OS-specific: registry on Windows, plist on macOS, `~/.java` on Linux. - -**Supported types:** `String`, `Int`, `Long`, `Float`, `Double`, `Boolean`. - -**Custom types** require a registered `TypeConverter`. - -**Dependency:** - -```kotlin -implementation("dev.androidbroadcast.featured:javaprefs-provider") -``` - -**Setup:** - -```kotlin -// Uses the default node "featured" under the user root -val provider = JavaPreferencesConfigValueProvider() - -// Or supply a custom Preferences node -val node = Preferences.userRoot().node("com/example/app/flags") -val provider = JavaPreferencesConfigValueProvider(node) - -val configValues = ConfigValues(localProvider = provider) -``` - -**Custom type (enum) example:** - -```kotlin -provider.registerConverter(enumConverter()) -``` - -All I/O is dispatched on `Dispatchers.IO`. Active `observe` flows receive updates on every `set` or `resetOverride` call. - ---- - -## Built-in remote providers - -### FirebaseConfigValueProvider - -Wraps [Firebase Remote Config](https://firebase.google.com/docs/remote-config). Remote values override local values when present. - -**Supported types natively:** `String`, `Boolean`, `Int`, `Long`, `Double`, `Float`. - -Enum types are resolved automatically by name — no explicit converter needed. For other custom types, register a `Converter` on the `converters` property: - -```kotlin -provider.converters.put(Converter { MyEnum.fromString(it.asString()) }) -``` - -**Dependency:** - -```kotlin -implementation("dev.androidbroadcast.featured:firebase-provider") -``` - -**Setup:** - -```kotlin -val configValues = ConfigValues( - localProvider = DataStoreConfigValueProvider(dataStore), - remoteProvider = FirebaseConfigValueProvider(), -) - -// Fetch and activate on app start — call from a coroutine -lifecycleScope.launch { configValues.fetch() } -``` - -Pass a custom `FirebaseRemoteConfig` instance if you manage the Firebase lifecycle yourself: - -```kotlin -FirebaseConfigValueProvider(remoteConfig = FirebaseRemoteConfig.getInstance()) -``` - -**Fetch strategy:** - -- `configValues.fetch()` calls `fetchAndActivate()` by default — values become immediately available after the call returns. -- Pass `activate = false` to fetch without activating immediately: - -```kotlin -configValues.fetch(activate = false) -// activate at the right moment later -configValues.fetch(activate = true) -``` - -- A `FetchException` is thrown on network errors, timeouts, or service unavailability. Wrap the call in a try/catch and implement exponential backoff for retries. - -**Firebase project setup:** - -1. Add `google-services.json` (Android) or `GoogleService-Info.plist` (iOS) to your project. -2. In the [Firebase console](https://console.firebase.google.com/), navigate to **Remote Config**. -3. Add parameters whose keys match your `ConfigParam.key` values. -4. Publish the configuration, then call `configValues.fetch()` at app start. - ---- - -## Custom types - -All providers that serialize values as strings (`DataStoreConfigValueProvider`, `SharedPreferencesProviderConfig`, `JavaPreferencesConfigValueProvider`) support custom types via `TypeConverter`. - -The library ships `enumConverter()` for any enum class: - -```kotlin -enum class Theme { LIGHT, DARK, SYSTEM } - -provider.registerConverter(enumConverter()) -``` - -For non-enum types, implement `TypeConverter` directly: - -```kotlin -val uuidConverter = TypeConverter( - fromString = { UUID.fromString(it) }, - toString = UUID::toString, -) -provider.registerConverter(UUID::class, uuidConverter) -``` - -Register converters **before** the first `get`, `set`, or `observe` call for the corresponding type. - ---- - -## Writing a custom provider - -### Custom local provider - -Implement `LocalConfigValueProvider`: - -```kotlin -class MyLocalProvider : LocalConfigValueProvider { - override suspend fun get(param: ConfigParam): ConfigValue? { … } - override fun observe(param: ConfigParam): Flow> { … } - override suspend fun set(param: ConfigParam, value: T) { … } - override suspend fun resetOverride(param: ConfigParam) { … } - override suspend fun clear() { … } -} -``` - -### Custom remote provider - -Implement `RemoteConfigValueProvider`: - -```kotlin -class MyRemoteProvider : RemoteConfigValueProvider { - override suspend fun fetch(activate: Boolean) { /* fetch from your backend */ } - override suspend fun get(param: ConfigParam): ConfigValue? { … } - override fun observe(param: ConfigParam): Flow> { … } -} -``` - ---- - -## Provider composition - -`ConfigValues` accepts one local provider and one remote provider: - -```kotlin -val configValues = ConfigValues( - localProvider = DataStoreConfigValueProvider(dataStore), - remoteProvider = FirebaseConfigValueProvider(), -) -``` - -Either provider is optional, but at least one must be supplied. - -## Provider resolution order - -When `ConfigValues.getValue(param)` is called: - -1. Check remote provider — return value if present. -2. Check local provider — return value if present. -3. Return `ConfigValue(param, param.defaultValue, Source.DEFAULT)`. - -Overrides written via `configValues.override(param, value)` are written to the **local** provider and survive remote fetches. - -## Value source - -Every `ConfigValue` carries a `source` field indicating where the value came from: - -| Source | Meaning | -|---|---| -| `REMOTE` | Fetched from the remote provider | -| `REMOTE_DEFAULT` | Remote provider returned its own default (e.g. Firebase in-app default) | -| `LOCAL` | Written by a local provider override | -| `DEFAULT` | Fell back to `ConfigParam.defaultValue` | -| `UNKNOWN` | Source could not be determined | - -Use `source` for debugging or analytics to understand which layer is serving each value. diff --git a/docs/guides/r8-verification.md b/docs/guides/r8-verification.md deleted file mode 100644 index 51cffe8..0000000 --- a/docs/guides/r8-verification.md +++ /dev/null @@ -1,78 +0,0 @@ -# R8 Dead-Code Elimination Verification - -## Why it matters - -Featured's core guarantee for local flags is that when a flag value is fixed at build time, -the code reachable only through the disabled branch is completely removed from the final APK. -This relies on R8 honouring the `-assumevalues` ProGuard rules generated by -`ProguardRulesGenerator`. - -A rule that is syntactically correct but semantically wrong would silently fail to eliminate -dead code. The `featured-shrinker-tests` module gives automated, deterministic verification -that the exact rule format produced by the plugin is sufficient for R8 to perform DCE. - -## How it works - -The tests use a three-step synthetic pipeline: - -1. **Bytecode generation (ASM)** — `SyntheticBytecodeFactory` builds `.class` files in - memory that mirror the structure the plugin generates at build time: a `ConfigValues` - holder, an extensions class that reads from it, branch-target classes (`IfBranchCode`, - `ElseBranchCode`, `PositiveCountCode`), and a caller entry point. - -2. **Rules generation** — `ProguardRulesWriter` writes `.pro` files in the exact format - `ProguardRulesGenerator` produces, optionally including the `-assumevalues` block. - -3. **R8 invocation** — `R8TestHarness.runR8()` calls R8 programmatically via - `R8Command.builder()`, producing an output JAR with DCE applied. - -After each run, `JarAssertions` inspects the output JAR and asserts which classes are -present or absent, proving that the rule caused (or did not cause) elimination. - -## Test scenarios - -### Boolean flags (`R8BooleanFlagEliminationTest`) - -| Test | Rule | Expected | -|------|------|----------| -| `if-branch class is eliminated when boolean flag returns false` | `-assumevalues … return false` | `IfBranchCode` absent; `ElseBranchCode` present | -| `else-branch class is eliminated when boolean flag returns true` | `-assumevalues … return true` | `ElseBranchCode` absent; `IfBranchCode` present | -| `both branch classes survive when no boolean assumevalues rule is present` | No `-assumevalues` | Both classes present | - -The third test is a control: it proves that elimination is caused by the rule, not by R8's -own constant-folding. - -### Int flags (`R8IntFlagEliminationTest`) - -| Test | Rule | Expected | -|------|------|----------| -| `guarded class is eliminated when int flag is assumed to return zero` | `-assumevalues … return 0` | `PositiveCountCode` absent; `IntCaller` present | -| `guarded class survives when int flag has no assumevalues rule` | No `-assumevalues` | Both classes present | - -With `-assumevalues return 0`, R8 constant-folds `0 > 0` to `false` and eliminates the -guarded block entirely. - -## Running the tests - -```bash -./gradlew :featured-shrinker-tests:test -``` - -To run only one test class: - -```bash -./gradlew :featured-shrinker-tests:test --tests "dev.androidbroadcast.featured.shrinker.r8.R8BooleanFlagEliminationTest" -``` - -## Adding new scenarios - -1. **New flag type** — add bytecode generators in `SyntheticBytecodeFactory.kt`, JAR - assembler functions in `JarAssembler.kt`, rule writers in `ProguardRulesWriter.kt`, and - a new test class in `r8/` that extends `R8TestHarness`. - -2. **New rule variant** — add a `write*Rules()` function in `ProguardRulesWriter.kt` and a - corresponding `@Test` method in the relevant test class. - -3. **Verifying a rule format change** — update the `write*Rules()` function to match the - new format produced by `ProguardRulesGenerator`, then run the tests to confirm DCE still - works. diff --git a/docs/index.md b/docs/index.md deleted file mode 100644 index 57ad4bc..0000000 --- a/docs/index.md +++ /dev/null @@ -1,32 +0,0 @@ -# Featured - -[![CI](https://github.com/AndroidBroadcast/Featured/actions/workflows/ci.yml/badge.svg)](https://github.com/AndroidBroadcast/Featured/actions/workflows/ci.yml) -[![Maven Central](https://img.shields.io/maven-central/v/dev.androidbroadcast.featured/core.svg?label=Maven%20Central)](https://central.sonatype.com/search?q=dev.androidbroadcast.featured) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/AndroidBroadcast/Featured/blob/main/LICENSE) - -**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. - -## 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` | Declares a named, typed configuration key with a default value | -| `ConfigValue` | 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 | - -## Quick links - -- [Getting Started](getting-started.md) — installation and first flag in 5 minutes -- [Android guide](guides/android.md) — DataStore, Compose, debug UI -- [iOS guide](guides/ios.md) — SKIE interop, Swift DCE via xcconfig -- [JVM guide](guides/jvm.md) — standalone JVM usage -- [Providers](guides/providers.md) — all built-in providers explained -- [API Reference](api/index.md) — full KDoc-generated reference diff --git a/docs/ios-integration.md b/docs/ios-integration.md deleted file mode 100644 index cd3d83e..0000000 --- a/docs/ios-integration.md +++ /dev/null @@ -1,147 +0,0 @@ -# iOS Integration Guide: Swift Dead Code Elimination with #if - -This guide explains how to use the `featured-gradle-plugin` xcconfig output to eliminate -disabled feature-flag code paths from your iOS Release binaries at compile time. - -## How it works - -For every local boolean flag declared in `featured { localFlags { } }` with `default = false` -in your shared Kotlin module, the plugin generates a `DISABLE_` Swift compilation -condition. Xcode reads these conditions from an xcconfig file and passes them to the Swift -compiler, which removes any `#if !DISABLE_*` guarded block from the binary entirely — -with no runtime overhead. - -### Key transformation - -| Kotlin flag key | Generated condition | -|----------------------|--------------------------| -| `new_checkout` | `DISABLE_NEW_CHECKOUT` | -| `experimentalUi` | `DISABLE_EXPERIMENTAL_UI`| -| `my_feature_flag` | `DISABLE_MY_FEATURE_FLAG`| - -Only local boolean flags with `default = false` produce a condition. Flags with -`default = true`, non-boolean flags, and flags declared in `remoteFlags { }` are excluded. - -## Step 1: Generate the xcconfig file - -Run the Gradle task from the module that contains your `featured { localFlags { } }` DSL declarations -(usually your `shared` or `core` module): - -```bash -./gradlew :shared:generateXcconfig -``` - -The file is written to: - -``` -shared/build/featured/FeatureFlags.generated.xcconfig -``` - -Example output: - -```xcconfig -# Auto-generated by featured-gradle-plugin — do not edit -# Include this file in your Xcode Release configuration -SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) DISABLE_NEW_CHECKOUT DISABLE_EXPERIMENTAL_UI -``` - -## Step2: Make the file available to Xcode - -The generated file lives inside the Gradle build directory and is not committed to source -control. Copy or symlink it to a stable path inside your Xcode project tree. - -**Convention:** `iosApp/Configuration/FeatureFlags.generated.xcconfig` - -```bash -# Copy approach (run after every generateXcconfig invocation) -cp shared/build/featured/FeatureFlags.generated.xcconfig \ - iosApp/Configuration/FeatureFlags.generated.xcconfig - -# Symlink approach (resolved automatically on every build) -ln -sf ../../shared/build/featured/FeatureFlags.generated.xcconfig \ - iosApp/Configuration/FeatureFlags.generated.xcconfig -``` - -Add the generated file to `.gitignore` when using the copy approach: - -```gitignore -iosApp/Configuration/FeatureFlags.generated.xcconfig -``` - -The repository ships a placeholder file at this path so the Xcode project reference -remains valid in a clean checkout. - -## Step3: Configure Xcode (one-time) - -1. Open `iosApp/iosApp.xcodeproj` in Xcode. -2. Select the project in the Project Navigator → **Info** tab → **Configurations**. -3. Expand the **Release** configuration. -4. For your app target, set the configuration file to **FeatureFlags.generated.xcconfig**. - -Only configure the Release configuration. Debug builds intentionally omit the xcconfig -so all features remain reachable during development. - -## Step4: Guard Swift entry points with #if - -Wrap every Swift entry point for a feature behind the corresponding `#if !DISABLE_*` -condition. The Swift compiler removes the entire guarded block from Release binaries. - -### View entry point - -```swift -// Entry point guarded by local boolean flag new_checkout (default = false) -#if !DISABLE_NEW_CHECKOUT -NewCheckoutButton() -#endif -``` - -### Deeplink handler - -```swift -#if !DISABLE_NEW_CHECKOUT -case .newCheckout: NewCheckoutCoordinator.start() -#endif -``` - -### Tab bar item - -```swift -#if !DISABLE_NEW_CHECKOUT -TabItem(title: "Checkout", systemImage: "cart") { - NewCheckoutView() -} -#endif -``` - -### AppDelegate / SceneDelegate - -```swift -#if !DISABLE_NEW_CHECKOUT -setupNewCheckoutObservers() -#endif -``` - ---- - -## Automation: regenerate on every build - -To keep the xcconfig in sync without a manual step, add a pre-build Run Script phase -to your Xcode target (before the Compile Sources phase): - -```bash -cd "${SRCROOT}/.." -./gradlew :shared:generateXcconfig --quiet -cp shared/build/featured/FeatureFlags.generated.xcconfig \ - iosApp/Configuration/FeatureFlags.generated.xcconfig -``` - -Set **Based on dependency analysis** to **off** so it runs on every build. - -## Reference - -- `FeatureFlags.swift` — Swift wrapper with usage examples and setup guidance -- `iosApp/Configuration/FeatureFlags.generated.xcconfig` — placeholder / copy destination -- `generateIosConstVal` — Gradle task that generates `expect`/`actual const val` for local flags -- `resolveFeatureFlags` — Gradle task that resolves DSL-declared flags before code generation -- `GenerateXcconfigTask.kt` — Gradle task that writes the xcconfig -- `XcconfigGenerator.kt` — key transformation and file generation logic diff --git a/docs/known-limitations.md b/docs/known-limitations.md deleted file mode 100644 index 415b5f0..0000000 --- a/docs/known-limitations.md +++ /dev/null @@ -1,49 +0,0 @@ -# Known Limitations - -This document tracks behaviour gaps and deferred work that consumers of -`featured-gradle-plugin` and related modules should be aware of. Each entry -links to a tracking issue and the milestone in which it is expected to be -resolved. - -## Configuration Cache - -`featured-gradle-plugin` officially supports the Gradle Configuration Cache -on Gradle 9.x and AGP 9.x. Verification artefacts: - -- `docs/cc-verification/fixture-report-2026-05-17.md` — fixture project audit -- `docs/cc-verification/sample-report-2026-05-17.md` — sample modules audit -- `docs/cc-verification/agp-propagation-check-2026-05-16.md` — AGP provider - propagation audit (see `AndroidProguardWiring` fallback) - -Known upstream gaps observed during verification, if any, are listed in the -sample report under "Per-violation table". - -## Isolated projects - -`featured-gradle-plugin` is **Configuration-Cache safe** but **not -isolated-projects safe**. - -Source: `FeaturedPlugin.kt:157` — `wireToRootAggregator()` calls -`target.rootProject` to lazily register the `scanAllLocalFlags` aggregator on -the root project. Cross-project mutation from a non-root project violates the -isolated-projects contract. - -The behaviour is intentional for `1.0.0-Beta`: it lets consumers `apply` the -plugin in any subproject without manual root wiring, which is the dominant -usage pattern today. - -**Migration path (v1.1.0):** convert the aggregator wiring into a settings -plugin, or change the contract so consumers register the aggregator once in -the root `build.gradle.kts` and subproject plugins only `dependsOn` it. - -Tracking issue: -[androidbroadcast/Featured#186](https://github.com/androidbroadcast/Featured/issues/186) -(milestone `v1.1.0`). - -## Third-party plugin CC gaps - -Third-party Gradle plugins occasionally introduce Configuration Cache -violations through transitive plugin application. We track such gaps in the -sample audit (`docs/cc-verification/sample-report-2026-05-17.md`) when they -surface. None of these are caused by `featured-gradle-plugin` itself; the -plugin's own task graph is CC-clean per the fixture audit. diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index 2975870..91b20a2 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -19,7 +19,7 @@ struct ContentView: View { // #if entry point pattern demo: DISABLE_NEW_CHECKOUT is set in // FeatureFlags.generated.xcconfig when @LocalFlag new_checkout has // defaultValue = false. The compiler removes this block in Release. - // See FeatureFlags.swift and docs/ios-integration.md for setup. + // See FeatureFlags.swift and https://github.com/AndroidBroadcast/Featured/wiki/iOS-DCE-xcconfig for setup. #if !DISABLE_NEW_CHECKOUT NewCheckoutBanner() #endif diff --git a/iosApp/iosApp/FeatureFlags.swift b/iosApp/iosApp/FeatureFlags.swift index 2664c3a..0b84f8a 100644 --- a/iosApp/iosApp/FeatureFlags.swift +++ b/iosApp/iosApp/FeatureFlags.swift @@ -29,7 +29,7 @@ import FeaturedSampleApp // // When the flag is defaultValue = false the compiler strips the guarded code // from the Release binary entirely, with zero runtime overhead. -// See docs/ios-integration.md for the full integration guide. +// See https://github.com/AndroidBroadcast/Featured/wiki/iOS-DCE-xcconfig for the full integration guide. /// A type-safe wrapper around a KMP CoreConfigParam. /// diff --git a/mkdocs.yml b/mkdocs.yml deleted file mode 100644 index 5eaba4d..0000000 --- a/mkdocs.yml +++ /dev/null @@ -1,66 +0,0 @@ -site_name: Featured -site_description: Type-safe, reactive feature-flag and configuration management for Kotlin Multiplatform -site_url: https://androidbroadcast.github.io/Featured/ -repo_name: AndroidBroadcast/Featured -repo_url: https://github.com/AndroidBroadcast/Featured -edit_uri: edit/main/docs/ - -theme: - name: material - palette: - - media: "(prefers-color-scheme: light)" - scheme: default - primary: deep purple - accent: purple - toggle: - icon: material/brightness-7 - name: Switch to dark mode - - media: "(prefers-color-scheme: dark)" - scheme: slate - primary: deep purple - accent: purple - toggle: - icon: material/brightness-4 - name: Switch to light mode - features: - - navigation.tabs - - navigation.sections - - navigation.top - - search.suggest - - search.highlight - - content.code.copy - - content.tabs.link - -exclude_docs: | - superpowers/ - -plugins: - - search - -markdown_extensions: - - admonition - - pymdownx.details - - pymdownx.superfences - - pymdownx.highlight: - anchor_linenums: true - - pymdownx.inlinehilite - - pymdownx.snippets - - pymdownx.tabbed: - alternate_style: true - - tables - - toc: - permalink: true - -nav: - - Home: index.md - - Getting Started: getting-started.md - - Guides: - - Android: guides/android.md - - iOS: guides/ios.md - - JVM: guides/jvm.md - - Providers: guides/providers.md - - Best Practices: guides/best-practices.md - - R8 DCE Verification: guides/r8-verification.md - - iOS Dead-Code Elimination: ios-integration.md - - API Reference: api/index.md - - Changelog: changelog.md From 8054eef761608bd7e7fbd5f83a0f69f82099abe4 Mon Sep 17 00:00:00 2001 From: Kirill Rozov Date: Tue, 19 May 2026 08:54:25 +0300 Subject: [PATCH 07/15] Publish per-module Featured manifest (producer side) (#197) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Publish per-module Featured manifest as consumable artifact Adds a new consumable Gradle configuration `featuredManifest` (schema v1, Usage = "featured-manifest") that publishes a per-module `featured-manifest.json` description of the module's local and remote feature flags. Each `dev.androidbroadcast.featured` plugin application now registers a `generateFeaturedManifest` @CacheableTask that maps LocalFlagEntry rows from `flags.txt` into a self-contained FlagDescriptor list (key, propertyName, kind, valueType, defaultValue, enumTypeFqn) and serialises the result via kotlinx-serialization. The manifest is the producer side of the multi-module aggregation redesign: a follow-up PR introduces the aggregator that resolves all manifests through normal dependency resolution and generates the GeneratedFeaturedRegistry. The existing five flag-generation tasks are unchanged. A separate Maven-publish guard is intentionally omitted — custom consumable configurations are not auto-published by the Java / KMP / AGP software components, and a KMP smoke fixture gates the invariant that no `featured-manifest` variant appears in the published .module metadata. Co-Authored-By: Claude Opus 4.7 * Address review: normalize SDK path before writing local.properties cubic-dev-ai PR review (cf338244) flagged that FeaturedManifestIntegrationTest writes Android SDK path directly into local.properties via File.absolutePath, which on Windows yields raw backslashes — Java's .properties parser treats backslash as an escape character and would corrupt the path. Switch to File.invariantSeparatorsPath, which uniformly emits forward slashes. An identical pattern exists in the pre-existing FeaturedPluginIntegrationTest; that file is out of PR A scope and will be aligned in a separate cleanup PR. Co-Authored-By: Claude Opus 4.7 --------- Co-authored-by: Claude Opus 4.7 --- CHANGELOG.md | 4 + featured-gradle-plugin/build.gradle.kts | 2 + .../featured/gradle/FeaturedPlugin.kt | 81 ++++++ .../gradle/manifest/FeaturedManifest.kt | 145 ++++++++++ .../manifest/FeaturedManifestContract.kt | 23 ++ .../manifest/GenerateFeaturedManifestTask.kt | 140 ++++++++++ .../build.gradle.kts | 7 + .../settings.gradle.kts | 9 + .../kmp-publish-project/build.gradle.kts | 1 + .../kmp-publish-project/gradle.properties | 1 + .../module/build.gradle.kts | 31 +++ .../module/src/commonMain/kotlin/.gitkeep | 0 .../kmp-publish-project/settings.gradle.kts | 17 ++ .../app/build.gradle.kts | 27 ++ .../app/src/main/AndroidManifest.xml | 1 + .../manifest-publish-project/build.gradle.kts | 1 + .../gradle.properties | 3 + .../settings.gradle.kts | 31 +++ .../manifest/FeaturedKmpPublicationTest.kt | 73 +++++ .../FeaturedManifestConfigurationTest.kt | 109 ++++++++ .../manifest/FeaturedManifestEmptyDslTest.kt | 62 +++++ .../FeaturedManifestIntegrationTest.kt | 187 +++++++++++++ .../manifest/FeaturedManifestMappingTest.kt | 250 +++++++++++++++++ .../FeaturedManifestSerializationTest.kt | 252 ++++++++++++++++++ ...ateFeaturedManifestTaskRegistrationTest.kt | 103 +++++++ .../gradle/manifest/TestFixtureSupport.kt | 50 ++++ gradle/libs.versions.toml | 3 + 27 files changed, 1613 insertions(+) create mode 100644 featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifest.kt create mode 100644 featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestContract.kt create mode 100644 featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/GenerateFeaturedManifestTask.kt create mode 100644 featured-gradle-plugin/src/test/fixtures/jvm-empty-featured-project/build.gradle.kts create mode 100644 featured-gradle-plugin/src/test/fixtures/jvm-empty-featured-project/settings.gradle.kts create mode 100644 featured-gradle-plugin/src/test/fixtures/kmp-publish-project/build.gradle.kts create mode 100644 featured-gradle-plugin/src/test/fixtures/kmp-publish-project/gradle.properties create mode 100644 featured-gradle-plugin/src/test/fixtures/kmp-publish-project/module/build.gradle.kts create mode 100644 featured-gradle-plugin/src/test/fixtures/kmp-publish-project/module/src/commonMain/kotlin/.gitkeep create mode 100644 featured-gradle-plugin/src/test/fixtures/kmp-publish-project/settings.gradle.kts create mode 100644 featured-gradle-plugin/src/test/fixtures/manifest-publish-project/app/build.gradle.kts create mode 100644 featured-gradle-plugin/src/test/fixtures/manifest-publish-project/app/src/main/AndroidManifest.xml create mode 100644 featured-gradle-plugin/src/test/fixtures/manifest-publish-project/build.gradle.kts create mode 100644 featured-gradle-plugin/src/test/fixtures/manifest-publish-project/gradle.properties create mode 100644 featured-gradle-plugin/src/test/fixtures/manifest-publish-project/settings.gradle.kts create mode 100644 featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedKmpPublicationTest.kt create mode 100644 featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestConfigurationTest.kt create mode 100644 featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestEmptyDslTest.kt create mode 100644 featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestIntegrationTest.kt create mode 100644 featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestMappingTest.kt create mode 100644 featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestSerializationTest.kt create mode 100644 featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/GenerateFeaturedManifestTaskRegistrationTest.kt create mode 100644 featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/TestFixtureSupport.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index f8aea62..cfcb5e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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. + ## [1.0.0-Beta1] - 2026-05-17 ### Added diff --git a/featured-gradle-plugin/build.gradle.kts b/featured-gradle-plugin/build.gradle.kts index 827e8b4..36b432e 100644 --- a/featured-gradle-plugin/build.gradle.kts +++ b/featured-gradle-plugin/build.gradle.kts @@ -1,5 +1,6 @@ plugins { alias(libs.plugins.kotlinJvm) + alias(libs.plugins.kotlinSerialization) `java-gradle-plugin` alias(libs.plugins.mavenPublish) } @@ -70,6 +71,7 @@ tasks.pluginUnderTestMetadata { dependencies { compileOnly("com.android.tools.build:gradle:9.1.0") + implementation(libs.kotlinx.serialization.json) // Inject AGP into the TestKit subprocess via pluginUnderTestMetadata so that the Featured // plugin can access AndroidComponentsExtension when wireProguardToVariants() is called. testPluginClasspath("com.android.tools.build:gradle:9.1.0") diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPlugin.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPlugin.kt index 95d9425..55bd264 100644 --- a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPlugin.kt +++ b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPlugin.kt @@ -1,7 +1,14 @@ package dev.androidbroadcast.featured.gradle +import dev.androidbroadcast.featured.gradle.manifest.FEATURED_MANIFEST_CONFIGURATION_NAME +import dev.androidbroadcast.featured.gradle.manifest.FEATURED_MANIFEST_USAGE +import dev.androidbroadcast.featured.gradle.manifest.GENERATE_FEATURED_MANIFEST_TASK_NAME +import dev.androidbroadcast.featured.gradle.manifest.GenerateFeaturedManifestTask +import dev.androidbroadcast.featured.gradle.manifest.SCHEMA_VERSION +import dev.androidbroadcast.featured.gradle.manifest.schemaMajorAttr import org.gradle.api.Plugin import org.gradle.api.Project +import org.gradle.api.attributes.Usage import org.gradle.api.tasks.TaskProvider internal const val RESOLVE_FLAGS_TASK_NAME = "resolveFeatureFlags" @@ -54,6 +61,8 @@ public class FeaturedPlugin : Plugin { val proguardTask = registerProguardTask(target, resolveTask) registerIosConstValTask(target, resolveTask) registerXcconfigTask(target, resolveTask) + val manifestTask = registerManifestTask(target, resolveTask) + registerFeaturedManifestConfiguration(target, manifestTask) wireToRootAggregator(target, resolveTask) listOf("com.android.application", "com.android.library").forEach { pluginId -> target.plugins.withId(pluginId) { @@ -145,6 +154,78 @@ public class FeaturedPlugin : Plugin { } } + private fun registerManifestTask( + target: Project, + resolveTask: TaskProvider, + ): TaskProvider = + target.tasks.register( + GENERATE_FEATURED_MANIFEST_TASK_NAME, + GenerateFeaturedManifestTask::class.java, + ) { task -> + task.group = "featured" + task.description = "Generates featured-manifest.json for '${target.path}'." + task.flagsFile.set(resolveTask.flatMap { it.outputFile }) + // Snapshot target.path at configuration time — Project must not be captured by + // task state to remain Configuration Cache compliant. + task.modulePath.set(target.path) + task.outputFile.convention( + target.layout.buildDirectory.file("featured/featured-manifest.json"), + ) + task.dependsOn(resolveTask) + } + + private fun registerFeaturedManifestConfiguration( + target: Project, + manifestTask: TaskProvider, + ) { + // Register the schemaMajorAttr in the project's attribute schema so that Gradle's + // dependency resolution can match it precisely between producer and consumer. + target.dependencies.attributesSchema.attribute(schemaMajorAttr) + + val manifestConfiguration = + target.configurations.consumable( + FEATURED_MANIFEST_CONFIGURATION_NAME, + ) { config -> + config.attributes { + it.attribute( + Usage.USAGE_ATTRIBUTE, + target.objects.named(Usage::class.java, FEATURED_MANIFEST_USAGE), + ) + // Use SCHEMA_VERSION constant — not a hardcoded literal — so that a future bump + // automatically flows through to the attribute without a separate edit here. + it.attribute(schemaMajorAttr, SCHEMA_VERSION) + } + } + + // Wire the manifest file as an outgoing artifact. The provider chain already carries + // the task dependency; builtBy is explicit for IDE / --dry-run readability. + target.artifacts.add( + FEATURED_MANIFEST_CONFIGURATION_NAME, + manifestTask.flatMap { it.outputFile }, + ) { artifact -> + artifact.builtBy(manifestTask) + } + + // Maven-publish guard intentionally omitted (verified 2026-05-18 via KMP smoke test). + // + // The `java`, `java-library`, `kotlinMultiplatform`, and `com.android.library` software + // components do NOT auto-publish arbitrary consumable configurations. Each component + // exposes only the variants it explicitly added via `addVariantsFromConfiguration` — + // typically `apiElements` / `runtimeElements` for Java, target-specific + // `*ApiElements` / `*RuntimeElements` for KMP, build-type variants for AGP. + // + // The `featuredManifest` configuration is never registered with any of these components, + // so it does not appear in published Maven metadata. A guard via + // `withVariantsFromConfiguration(...) { skip() }` is not only unnecessary — it actively + // throws `Variant for configuration 'featuredManifest' does not exist in component` + // during publication because `withVariantsFromConfiguration` requires the variant to + // have been added first. + // + // The KMP smoke fixture (`kmp-publish-project`) and `FeaturedKmpPublicationTest` verify + // this invariant: a KMP module that applies both `dev.androidbroadcast.featured` and + // `maven-publish` produces module metadata with no `featured-manifest` Usage variant. + } + /** * Ensures the root project has a `scanAllLocalFlags` aggregation task and wires * [resolveTask] into it. `./gradlew scanAllLocalFlags` triggers flag resolution diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifest.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifest.kt new file mode 100644 index 0000000..3af02c9 --- /dev/null +++ b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifest.kt @@ -0,0 +1,145 @@ +package dev.androidbroadcast.featured.gradle.manifest + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +/** + * Root manifest published by each module that applies the Featured Gradle plugin. + * + * **Public contract is the JSON wire format documented below.** These Kotlin types are an + * internal producer-side helper. The consumer (PR B aggregator) may implement its own + * deserialization model independently — renaming internal Kotlin fields does NOT break + * the contract; changing the JSON wire format (field names, field semantics, enum variant + * names) DOES break it and requires a [SCHEMA_VERSION] bump. + * + * --- + * + * ## Example JSON + * + * ```json + * { + * "schemaVersion": 1, + * "modulePath": ":feature:checkout", + * "flags": [ + * { + * "key": "dark_mode", + * "propertyName": "darkMode", + * "kind": "LOCAL", + * "valueType": "BOOLEAN", + * "defaultValue": "false" + * }, + * { + * "key": "promo_banner", + * "propertyName": "promoBanner", + * "kind": "REMOTE", + * "valueType": "STRING", + * "defaultValue": "hello world", + * "description": "Show promo banner" + * }, + * { + * "key": "checkout_variant", + * "propertyName": "checkoutVariant", + * "kind": "LOCAL", + * "valueType": "ENUM", + * "defaultValue": "LEGACY", + * "enumTypeFqn": "com.example.CheckoutVariant" + * } + * ] + * } + * ``` + * + * --- + * + * ## Field semantics + * + * - **`modulePath`** — Gradle `Project.path` in the `:foo:bar` format. Root project is `":"`. + * - **`propertyName`** — camelCase Kotlin property name for the aggregator to generate. + * Derived from `key` via `toCamelCase()` in the producer. + * - **`kind`** — `LOCAL` for flags declared in `localFlags { }`, `REMOTE` for `remoteFlags { }`. + * - **`defaultValue`** — raw default value as a string. For `STRING` valueType, the enclosing + * quotes (added by `FlagContainer.string()`) are removed by the producer; the stored value + * is the bare string (e.g. `hello world`, not `"hello world"`). For `ENUM` valueType, only + * the constant name is stored (e.g. `LEGACY`, not `EnumClass.LEGACY`) so that the aggregator + * can pass it directly to `enumValueOf(defaultValue)`. + * - **`enumTypeFqn`** — fully-qualified name of the enum class when `valueType` is `ENUM`; + * `null` for all other types. + * - **`description`**, **`category`**, **`expiresAt`** — optional metadata passed through from + * the DSL. Absent from JSON when null (`explicitNulls = false`). + * + * --- + * + * ## Evolvability policy + * + * | Change | Action | + * |---------------------------------------------|----------------------------------------------| + * | Add optional field with a default | Additive — no schema bump | + * | Remove or rename existing field | Breaking — bump [SCHEMA_VERSION] + `schema-major` attribute | + * | Add new enum variant in [FlagKind]/[ValueType] | Breaking — bump major | + * | Change semantics of existing field | Breaking — bump major | + * + * --- + * + * ## ABI status + * + * The `featured-manifest` Usage attribute and the `schema-major` Gradle attribute are stable + * consumer-facing contracts. See [FeaturedManifestContract] for the attribute constants. + */ +@Serializable +internal data class FeaturedManifest( + val schemaVersion: Int, + val modulePath: String, + // No default value — guarantees that an empty list is serialized as "flags":[] + // rather than being omitted when encodeDefaults = false. + val flags: List, +) + +/** + * Describes a single feature flag declared via the `featured { }` DSL. + * + * Null optional fields are omitted from the JSON output (`explicitNulls = false` in + * [FeaturedManifestJson]). + */ +@Serializable +internal data class FlagDescriptor( + val key: String, + val propertyName: String, + val kind: FlagKind, + val valueType: ValueType, + val defaultValue: String, + val enumTypeFqn: String? = null, + val description: String? = null, + val category: String? = null, + val expiresAt: String? = null, +) + +/** Whether the flag is declared in `localFlags { }` or `remoteFlags { }`. */ +@Serializable +internal enum class FlagKind { LOCAL, REMOTE } + +/** The Kotlin type of the flag's value. */ +@Serializable +internal enum class ValueType { BOOLEAN, INT, LONG, FLOAT, DOUBLE, STRING, ENUM } + +/** Wire-format schema version. Bump this (and the `schema-major` Gradle attribute) on breaking changes. */ +internal const val SCHEMA_VERSION = 1 + +/** + * Pre-configured [Json] instance used for both encoding and decoding [FeaturedManifest]. + * + * - `prettyPrint = true` — human-readable output for easier debugging and diff review. + * - `explicitNulls = false` — null optional fields are omitted from the JSON, keeping + * the output compact and forward-compatible. + * - `encodeDefaults = false` — Kotlin default values are not written if they match the + * declared default. Note: [FeaturedManifest.flags] intentionally has **no** default so + * it is always serialized, even when empty. + * - `ignoreUnknownKeys = true` — forward-compatible decoding: a consumer built against + * schema v1 can safely read a manifest produced by a future schema version that added + * optional fields. + */ +internal val FeaturedManifestJson = + Json { + prettyPrint = true + explicitNulls = false + encodeDefaults = false + ignoreUnknownKeys = true + } diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestContract.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestContract.kt new file mode 100644 index 0000000..f4a2409 --- /dev/null +++ b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestContract.kt @@ -0,0 +1,23 @@ +package dev.androidbroadcast.featured.gradle.manifest + +import org.gradle.api.attributes.Attribute + +internal const val GENERATE_FEATURED_MANIFEST_TASK_NAME = "generateFeaturedManifest" +internal const val FEATURED_MANIFEST_CONFIGURATION_NAME = "featuredManifest" +internal const val FEATURED_MANIFEST_USAGE = "featured-manifest" +internal const val SCHEMA_MAJOR_ATTRIBUTE_NAME = "dev.androidbroadcast.featured.schema-major" + +/** + * Gradle attribute that carries the major version of the Featured manifest schema. + * + * The attribute is declared as `Attribute` for ergonomic use from Kotlin call sites + * (`attribute(schemaMajorAttr, SCHEMA_VERSION)`). Under the hood Kotlin maps `Int` in a + * generic position to `java.lang.Integer`, which is the JVM boxed type Gradle uses for + * attribute equality. [Int.javaObjectType] (`Int::class.javaObjectType`) returns exactly + * `Integer.class`, so this is wire-compatible with a Java-side `Attribute`. + * + * The consumer (PR B aggregator) must declare the same [Attribute] instance — sharing + * this constant guarantees a single Attribute object. + */ +internal val schemaMajorAttr: Attribute = + Attribute.of(SCHEMA_MAJOR_ATTRIBUTE_NAME, Int::class.javaObjectType) diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/GenerateFeaturedManifestTask.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/GenerateFeaturedManifestTask.kt new file mode 100644 index 0000000..e08b3e5 --- /dev/null +++ b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/GenerateFeaturedManifestTask.kt @@ -0,0 +1,140 @@ +package dev.androidbroadcast.featured.gradle.manifest + +import dev.androidbroadcast.featured.gradle.LocalFlagEntry +import dev.androidbroadcast.featured.gradle.parseLocalFlagEntries +import kotlinx.serialization.encodeToString +import org.gradle.api.DefaultTask +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction + +/** + * Generates the per-module `featured-manifest.json` artifact consumed by the PR-B aggregator. + * + * Reads the flag report from [flagsFile] (produced by `resolveFeatureFlags`), maps each + * [LocalFlagEntry] to a [FlagDescriptor], and writes the result as a JSON document to + * [outputFile]. + * + * The output file is published via the `featuredManifest` consumable Gradle configuration + * so that downstream aggregator modules can resolve all per-module manifests through + * normal dependency resolution. + */ +@CacheableTask +internal abstract class GenerateFeaturedManifestTask : DefaultTask() { + /** + * The pipe-delimited flag report produced by `resolveFeatureFlags`. + * + * [PathSensitivity.NONE] is used because this is a generated intermediate file whose + * absolute path varies across machines and build directories. Only the file content + * matters for cache key computation — the path itself is irrelevant to correctness. + * This matches the sensitivity used by all other Generate* tasks that consume the same + * flags.txt file. + */ + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + abstract val flagsFile: RegularFileProperty + + /** + * Gradle `Project.path` for this module (e.g. `":feature:checkout"`, `":"`). + * + * Set as a snapshot string at configuration time (not as a lazy provider) to ensure + * Configuration Cache compliance — `Project` instances must not be captured by task + * state at execution time. + */ + @get:Input + abstract val modulePath: Property + + /** + * Output path for the generated `featured-manifest.json`. + * + * The convention `build/featured/featured-manifest.json` is wired by [FeaturedPlugin]; + * it keeps all Featured build outputs under a single directory alongside `flags.txt` + * and `proguard-featured.pro`. + */ + @get:OutputFile + abstract val outputFile: RegularFileProperty + + @TaskAction + fun generate() { + val path = modulePath.get() + require(path.startsWith(":")) { + "modulePath must be a Gradle path starting with ':', was '$path'" + } + + val entries = flagsFile.parseLocalFlagEntries() + val descriptors = entries.map { entry -> entry.toFlagDescriptor() } + val manifest = + FeaturedManifest( + schemaVersion = SCHEMA_VERSION, + modulePath = path, + flags = descriptors, + ) + + val outFile = outputFile.get().asFile + outFile.parentFile?.mkdirs() + outFile.writeText(FeaturedManifestJson.encodeToString(manifest)) + + logger.lifecycle( + "[featured] Generated manifest with ${descriptors.size} flag(s) → ${outFile.path}", + ) + } +} + +internal fun LocalFlagEntry.toFlagDescriptor(): FlagDescriptor { + val kind = if (isLocal) FlagKind.LOCAL else FlagKind.REMOTE + + val valueType = + if (isEnum) { + ValueType.ENUM + } else { + when (type) { + "Boolean" -> ValueType.BOOLEAN + + "Int" -> ValueType.INT + + "Long" -> ValueType.LONG + + "Float" -> ValueType.FLOAT + + "Double" -> ValueType.DOUBLE + + "String" -> ValueType.STRING + + // Explicit error with key name — ValueType.valueOf(type.uppercase()) would produce + // a cryptic "No enum constant" message with no context about which flag failed. + else -> error("Unsupported flag value type '$type' for key '$key'") + } + } + + val resolvedDefault = + when (valueType) { + // FlagContainer.string() wraps the default in escaped quotes: defaultValue = "\"hello\"". + // ScanResultParser stores it verbatim. Strip the surrounding quotes here so the aggregator + // can use the bare value without further processing. + ValueType.STRING -> defaultValue.removeSurrounding("\"") + + // ConfigParamGenerator writes qualified form "EnumType.VARIANT"; only the constant + // name is useful for the aggregator (it calls enumValueOf(defaultValue)). + ValueType.ENUM -> defaultValue.substringAfterLast('.') + + else -> defaultValue + } + + return FlagDescriptor( + key = key, + propertyName = propertyName, + kind = kind, + valueType = valueType, + defaultValue = resolvedDefault, + enumTypeFqn = type.takeIf { isEnum }, + description = description, + category = category, + expiresAt = expiresAt, + ) +} diff --git a/featured-gradle-plugin/src/test/fixtures/jvm-empty-featured-project/build.gradle.kts b/featured-gradle-plugin/src/test/fixtures/jvm-empty-featured-project/build.gradle.kts new file mode 100644 index 0000000..6147eee --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/jvm-empty-featured-project/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + id("java-library") + id("dev.androidbroadcast.featured") +} + +// No featured { } block — the plugin is applied with zero flag declarations. +// Expected: generateFeaturedManifest produces a manifest with an empty flags array. diff --git a/featured-gradle-plugin/src/test/fixtures/jvm-empty-featured-project/settings.gradle.kts b/featured-gradle-plugin/src/test/fixtures/jvm-empty-featured-project/settings.gradle.kts new file mode 100644 index 0000000..d0f39ff --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/jvm-empty-featured-project/settings.gradle.kts @@ -0,0 +1,9 @@ +// The Featured plugin is injected via GradleRunner.withPluginClasspath(). +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } +} + +rootProject.name = "jvm-empty-featured-project" diff --git a/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/build.gradle.kts b/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/build.gradle.kts new file mode 100644 index 0000000..b1af0dc --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/build.gradle.kts @@ -0,0 +1 @@ +// Root build file — no plugins applied at root level. diff --git a/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/gradle.properties b/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/gradle.properties new file mode 100644 index 0000000..5ad6974 --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/gradle.properties @@ -0,0 +1 @@ +org.gradle.configuration-cache=true diff --git a/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/module/build.gradle.kts b/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/module/build.gradle.kts new file mode 100644 index 0000000..ccf89fb --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/module/build.gradle.kts @@ -0,0 +1,31 @@ +plugins { + id("org.jetbrains.kotlin.multiplatform") version "2.3.10" + id("dev.androidbroadcast.featured") + id("maven-publish") +} + +kotlin { + jvm() + + sourceSets { + commonMain {} + } +} + +group = "com.example.test" +version = "0.1.0" + +featured { + localFlags { + boolean("debug_overlay", default = false) + } +} + +publishing { + repositories { + maven { + name = "TestLocal" + url = uri(layout.buildDirectory.dir("test-repo")) + } + } +} diff --git a/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/module/src/commonMain/kotlin/.gitkeep b/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/module/src/commonMain/kotlin/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/settings.gradle.kts b/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/settings.gradle.kts new file mode 100644 index 0000000..ac37701 --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/settings.gradle.kts @@ -0,0 +1,17 @@ +// The Featured plugin and Kotlin Multiplatform plugin are injected via GradleRunner.withPluginClasspath(). +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } +} + +dependencyResolutionManagement { + @Suppress("UnstableApiUsage") + repositories { + mavenCentral() + } +} + +rootProject.name = "kmp-publish-project" +include(":module") diff --git a/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/app/build.gradle.kts b/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/app/build.gradle.kts new file mode 100644 index 0000000..1b46cb2 --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/app/build.gradle.kts @@ -0,0 +1,27 @@ +plugins { + id("com.android.library") version "9.1.0" + id("dev.androidbroadcast.featured") +} + +android { + namespace = "com.example.testapp" + compileSdk = 36 + + defaultConfig { + minSdk = 24 + } +} + +featured { + localFlags { + boolean("dark_mode", default = false) { + category = "UI" + } + enum("checkout_variant", typeFqn = "com.example.CheckoutVariant", default = "LEGACY") + } + remoteFlags { + boolean("promo_banner", default = false) { + description = "Show promo banner" + } + } +} diff --git a/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/app/src/main/AndroidManifest.xml b/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..94cbbcf --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/app/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/build.gradle.kts b/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/build.gradle.kts new file mode 100644 index 0000000..b1af0dc --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/build.gradle.kts @@ -0,0 +1 @@ +// Root build file — no plugins applied at root level. diff --git a/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/gradle.properties b/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/gradle.properties new file mode 100644 index 0000000..d621155 --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/gradle.properties @@ -0,0 +1,3 @@ +android.useAndroidX=true +org.gradle.configuration-cache=true +org.gradle.unsafe.configuration-cache-problems=warn diff --git a/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/settings.gradle.kts b/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/settings.gradle.kts new file mode 100644 index 0000000..ae64af0 --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/settings.gradle.kts @@ -0,0 +1,31 @@ +// AGP and the Featured plugin are injected via GradleRunner.withPluginClasspath(). +pluginManagement { + repositories { + google { + mavenContent { + includeGroupAndSubgroups("androidx") + includeGroupAndSubgroups("com.android") + includeGroupAndSubgroups("com.google") + } + } + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + @Suppress("UnstableApiUsage") + repositories { + google { + mavenContent { + includeGroupAndSubgroups("androidx") + includeGroupAndSubgroups("com.android") + includeGroupAndSubgroups("com.google") + } + } + mavenCentral() + } +} + +rootProject.name = "manifest-publish-project" +include(":app") diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedKmpPublicationTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedKmpPublicationTest.kt new file mode 100644 index 0000000..a8a0686 --- /dev/null +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedKmpPublicationTest.kt @@ -0,0 +1,73 @@ +package dev.androidbroadcast.featured.gradle.manifest + +import org.gradle.testkit.runner.GradleRunner +import org.gradle.testkit.runner.TaskOutcome +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Smoke test that verifies the `featuredManifest` consumable configuration does NOT leak + * into the published Gradle Module Metadata (`.module` JSON) for a KMP module. + * + * Custom consumable configurations with arbitrary `Usage` attributes are not auto-published + * by the `kotlinMultiplatform`, `java`, `java-library`, or AGP software components — each + * component exposes only the variants it explicitly added via `addVariantsFromConfiguration`. + * This test is the mandatory gate that confirms that invariant in practice for KMP. + * + * Uses the `kmp-publish-project` fixture (JVM-only KMP module) to avoid requiring the + * Kotlin/Native toolchain download that `iosX64()` would trigger on CI. + */ +class FeaturedKmpPublicationTest { + @get:Rule + val tempFolder = TemporaryFolder() + + @Test + fun `publishing KMP module does not expose featuredManifest variant in module metadata`() { + val projectDir = tempFolder.newFolder("kmp-publish-project") + copyManifestFixture("kmp-publish-project", projectDir) + + val result = + GradleRunner + .create() + .withProjectDir(projectDir) + .withPluginClasspath() + .withArguments(":module:publishAllPublicationsToTestLocalRepository", "--stacktrace") + .forwardOutput() + .build() + + val outcome = result.task(":module:publishAllPublicationsToTestLocalRepository")?.outcome + assertTrue( + outcome == TaskOutcome.SUCCESS || outcome == TaskOutcome.UP_TO_DATE, + "Expected publish task to succeed, got $outcome\n${result.output}", + ) + + // Locate the generated .module file in the test-local repo. + val repoDir = projectDir.resolve("module/build/test-repo") + val moduleFiles = repoDir.walkTopDown().filter { it.extension == "module" }.toList() + assertTrue( + moduleFiles.isNotEmpty(), + "Expected at least one .module file in ${repoDir.path}; found none.\n${result.output}", + ) + + moduleFiles.forEach { moduleFile -> + val moduleJson = moduleFile.readText() + + // The featuredManifest Usage must not appear in any published variant. + assertFalse( + moduleJson.contains(FEATURED_MANIFEST_USAGE), + "Found '$FEATURED_MANIFEST_USAGE' in published .module metadata at ${moduleFile.path}.\n" + + "The featuredManifest configuration must be excluded from Maven publication.\n" + + "Content:\n$moduleJson", + ) + + // Sanity check: the .module file is valid and has variants. + assertTrue( + moduleJson.contains("\"variants\""), + "Expected 'variants' key in .module metadata at ${moduleFile.path}", + ) + } + } +} diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestConfigurationTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestConfigurationTest.kt new file mode 100644 index 0000000..5a0a5c1 --- /dev/null +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestConfigurationTest.kt @@ -0,0 +1,109 @@ +package dev.androidbroadcast.featured.gradle.manifest + +import org.gradle.api.attributes.Usage +import org.gradle.testfixtures.ProjectBuilder +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +@Suppress("UnstableApiUsage") +class FeaturedManifestConfigurationTest { + @Test + fun `featuredManifest configuration is registered`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured") + + val cfg = project.configurations.findByName(FEATURED_MANIFEST_CONFIGURATION_NAME) + assertNotNull(cfg, "Expected '$FEATURED_MANIFEST_CONFIGURATION_NAME' configuration to be registered") + } + + @Test + fun `featuredManifest configuration has correct consumable flags`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured") + + val cfg = project.configurations.findByName(FEATURED_MANIFEST_CONFIGURATION_NAME) + assertNotNull(cfg) + assertTrue(cfg.isCanBeConsumed, "Expected isCanBeConsumed = true") + assertTrue(!cfg.isCanBeResolved, "Expected isCanBeResolved = false") + assertTrue(!cfg.isCanBeDeclared, "Expected isCanBeDeclared = false") + } + + @Test + fun `featuredManifest configuration has usage attribute set to featured-manifest`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured") + + val cfg = project.configurations.findByName(FEATURED_MANIFEST_CONFIGURATION_NAME) + assertNotNull(cfg) + val usageAttr = cfg.attributes.getAttribute(Usage.USAGE_ATTRIBUTE) + assertNotNull(usageAttr, "Expected Usage attribute to be set") + assertEquals( + FEATURED_MANIFEST_USAGE, + usageAttr.name, + "Expected usage name '$FEATURED_MANIFEST_USAGE' but was '${usageAttr.name}'", + ) + } + + @Test + fun `featuredManifest configuration has schema-major attribute set to SCHEMA_VERSION`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured") + + val cfg = project.configurations.findByName(FEATURED_MANIFEST_CONFIGURATION_NAME) + assertNotNull(cfg) + val schemaAttr = cfg.attributes.getAttribute(schemaMajorAttr) + assertNotNull(schemaAttr, "Expected schema-major attribute to be set") + assertEquals( + SCHEMA_VERSION, + schemaAttr, + "Expected schema-major attribute = $SCHEMA_VERSION but was $schemaAttr", + ) + } + + @Test + fun `featuredManifest configuration has outgoing artifacts`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured") + + val cfg = project.configurations.findByName(FEATURED_MANIFEST_CONFIGURATION_NAME) + assertNotNull(cfg) + assertTrue( + cfg.outgoing.artifacts.isNotEmpty(), + "Expected at least one outgoing artifact on '$FEATURED_MANIFEST_CONFIGURATION_NAME'", + ) + } + + @Test + fun `featuredManifest artifact is built by generateFeaturedManifest task`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured") + + val cfg = project.configurations.findByName(FEATURED_MANIFEST_CONFIGURATION_NAME) + assertNotNull(cfg) + val deps = + cfg.outgoing.artifacts.buildDependencies + .getDependencies(null) + val taskNames = deps.map { it.name } + assertTrue( + taskNames.contains(GENERATE_FEATURED_MANIFEST_TASK_NAME), + "Expected artifact built by '$GENERATE_FEATURED_MANIFEST_TASK_NAME', got: $taskNames", + ) + } + + @Test + fun `accessing featuredManifest configuration does not eagerly realize generateFeaturedManifest task`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured") + + // Accessing the configuration by name must not trigger task realization. + project.configurations.findByName(FEATURED_MANIFEST_CONFIGURATION_NAME) + + // The task must still be present in the task graph (registered lazily). + assertTrue( + project.tasks.names.contains(GENERATE_FEATURED_MANIFEST_TASK_NAME), + "Expected '$GENERATE_FEATURED_MANIFEST_TASK_NAME' to be in task names (lazy)", + ) + } +} diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestEmptyDslTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestEmptyDslTest.kt new file mode 100644 index 0000000..500abbc --- /dev/null +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestEmptyDslTest.kt @@ -0,0 +1,62 @@ +package dev.androidbroadcast.featured.gradle.manifest + +import org.gradle.testkit.runner.GradleRunner +import org.gradle.testkit.runner.TaskOutcome +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Verifies that applying the Featured plugin without any `featured { }` DSL block generates + * a manifest with an empty `flags` array (not omitted) and the correct `schemaVersion`. + * + * Uses Gradle TestKit because `afterEvaluate` (which wires the DSL into the resolve task) + * is not triggered by ProjectBuilder — only a real Gradle execution resolves the full lifecycle. + */ +class FeaturedManifestEmptyDslTest { + @get:Rule + val tempFolder = TemporaryFolder() + + @Test + fun `generateFeaturedManifest with no DSL block produces manifest with empty flags array`() { + val projectDir = tempFolder.newFolder("jvm-empty-featured-project") + copyManifestFixture("jvm-empty-featured-project", projectDir) + + val result = + GradleRunner + .create() + .withProjectDir(projectDir) + .withPluginClasspath() + .withArguments(GENERATE_FEATURED_MANIFEST_TASK_NAME, "--stacktrace") + .forwardOutput() + .build() + + val outcome = result.task(":$GENERATE_FEATURED_MANIFEST_TASK_NAME")?.outcome + assertEquals( + TaskOutcome.SUCCESS, + outcome, + "Expected :$GENERATE_FEATURED_MANIFEST_TASK_NAME to succeed, got $outcome\n${result.output}", + ) + + val manifestFile = projectDir.resolve("build/featured/featured-manifest.json") + assertTrue(manifestFile.exists(), "Expected featured-manifest.json to be generated at ${manifestFile.path}") + + val rawJson = manifestFile.readText() + + // Parse and verify schema. + val manifest = FeaturedManifestJson.decodeFromString(rawJson) + assertEquals(SCHEMA_VERSION, manifest.schemaVersion) + // Plugin is applied to the rootProject in this single-module fixture, so the + // captured Project.path is ":". This verifies the contract for root-project apply. + assertEquals(":", manifest.modulePath, "Expected modulePath ':' for root project apply") + assertTrue(manifest.flags.isEmpty(), "Expected empty flags list, got: ${manifest.flags}") + + // Verify the raw JSON contains "flags": [] explicitly — not omitted. + assertTrue( + rawJson.contains("\"flags\": []"), + "Expected 'flags': [] in raw JSON — empty list must not be omitted, got:\n$rawJson", + ) + } +} diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestIntegrationTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestIntegrationTest.kt new file mode 100644 index 0000000..be7c28e --- /dev/null +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestIntegrationTest.kt @@ -0,0 +1,187 @@ +package dev.androidbroadcast.featured.gradle.manifest + +import org.gradle.testkit.runner.GradleRunner +import org.gradle.testkit.runner.TaskOutcome +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Integration tests for the per-module Featured manifest generation using the + * `manifest-publish-project` fixture (Android library with local and remote flags). + * + * Skipped when `ANDROID_HOME` / `ANDROID_SDK_ROOT` is not set. + */ +class FeaturedManifestIntegrationTest { + @get:Rule + val tempFolder = TemporaryFolder() + + private lateinit var projectDir: File + + @Before + fun setUp() { + val sdkDir = androidSdkDirOrNull() + assumeTrue( + "ANDROID_HOME or ANDROID_SDK_ROOT must be set to run integration tests", + sdkDir != null, + ) + + projectDir = tempFolder.newFolder("manifest-publish-project") + copyManifestFixture("manifest-publish-project", projectDir) + // invariantSeparatorsPath replaces backslashes with forward slashes — Java's `.properties` + // parser treats backslashes as escape characters, so a raw Windows SDK path would corrupt + // local.properties. + projectDir.resolve("local.properties").writeText("sdk.dir=${sdkDir!!.invariantSeparatorsPath}\n") + } + + @Test + fun `generateFeaturedManifest produces manifest with correct content`() { + val result = + gradleRunner() + .withArguments(":app:$GENERATE_FEATURED_MANIFEST_TASK_NAME", "--stacktrace") + .build() + + val outcome = result.task(":app:$GENERATE_FEATURED_MANIFEST_TASK_NAME")?.outcome + assertEquals( + TaskOutcome.SUCCESS, + outcome, + "Expected :app:$GENERATE_FEATURED_MANIFEST_TASK_NAME to succeed, got $outcome\n${result.output}", + ) + + val manifest = readManifest() + assertEquals(SCHEMA_VERSION, manifest.schemaVersion) + assertEquals(":app", manifest.modulePath) + assertEquals(3, manifest.flags.size, "Expected 3 flags (dark_mode, checkout_variant, promo_banner)") + + val darkMode = manifest.flags.first { it.key == "dark_mode" } + assertEquals(FlagKind.LOCAL, darkMode.kind) + assertEquals(ValueType.BOOLEAN, darkMode.valueType) + + val promoBanner = manifest.flags.first { it.key == "promo_banner" } + assertEquals(FlagKind.REMOTE, promoBanner.kind) + assertEquals(ValueType.BOOLEAN, promoBanner.valueType) + + val checkoutVariant = manifest.flags.first { it.key == "checkout_variant" } + assertEquals(FlagKind.LOCAL, checkoutVariant.kind) + assertEquals(ValueType.ENUM, checkoutVariant.valueType) + assertEquals("com.example.CheckoutVariant", checkoutVariant.enumTypeFqn) + } + + @Test + fun `second run without changes reports UP_TO_DATE`() { + gradleRunner() + .withArguments(":app:$GENERATE_FEATURED_MANIFEST_TASK_NAME") + .build() + + val result = + gradleRunner() + .withArguments(":app:$GENERATE_FEATURED_MANIFEST_TASK_NAME") + .build() + + val outcome = result.task(":app:$GENERATE_FEATURED_MANIFEST_TASK_NAME")?.outcome + assertTrue( + outcome == TaskOutcome.UP_TO_DATE || outcome == TaskOutcome.FROM_CACHE, + "Expected :app:$GENERATE_FEATURED_MANIFEST_TASK_NAME to be UP_TO_DATE or FROM_CACHE on second run, got $outcome", + ) + } + + @Test + fun `adding a new flag invalidates the task`() { + gradleRunner() + .withArguments(":app:$GENERATE_FEATURED_MANIFEST_TASK_NAME") + .build() + + // Append a new local flag to the app build script to invalidate inputs. + val buildFile = projectDir.resolve("app/build.gradle.kts") + buildFile.writeText( + buildFile.readText().replace( + "enum(\"checkout_variant\", typeFqn = \"com.example.CheckoutVariant\", default = \"LEGACY\")", + "enum(\"checkout_variant\", typeFqn = \"com.example.CheckoutVariant\", default = \"LEGACY\")\n" + + " int(\"max_retries\", default = 3)", + ), + ) + + val result = + gradleRunner() + .withArguments(":app:$GENERATE_FEATURED_MANIFEST_TASK_NAME") + .build() + + val outcome = result.task(":app:$GENERATE_FEATURED_MANIFEST_TASK_NAME")?.outcome + assertEquals( + TaskOutcome.SUCCESS, + outcome, + "Expected :app:$GENERATE_FEATURED_MANIFEST_TASK_NAME to re-run after input change, got $outcome", + ) + + val manifest = readManifest() + assertEquals(4, manifest.flags.size, "Expected 4 flags after adding max_retries") + } + + @Test + fun `configuration cache stores on first run`() { + val result = + gradleRunner() + .withArguments( + ":app:$GENERATE_FEATURED_MANIFEST_TASK_NAME", + "--configuration-cache", + "--configuration-cache-problems=warn", + ).build() + + // Gradle does not create build/reports/configuration-cache/ unless there are CC problems + // to report. The canonical signal that the cache was stored is the output line. + assertTrue( + result.output.contains("Configuration cache entry stored"), + "Expected 'Configuration cache entry stored' in output, got:\n${result.output}", + ) + } + + @Test + fun `configuration cache is reused on second run`() { + gradleRunner() + .withArguments( + ":app:$GENERATE_FEATURED_MANIFEST_TASK_NAME", + "--configuration-cache", + "--configuration-cache-problems=warn", + ).build() + + val secondRun = + gradleRunner() + .withArguments( + ":app:$GENERATE_FEATURED_MANIFEST_TASK_NAME", + "--configuration-cache", + "--configuration-cache-problems=warn", + ).build() + + assertTrue( + secondRun.output.contains("Configuration cache entry reused") || + secondRun.output.contains("Reusing configuration cache"), + "Expected CC reuse marker in second-run output, got:\n${secondRun.output}", + ) + } + + // Configuration exposure (consumable flags, Usage / schema-major attributes, outgoing + // artifact and task dependency) is covered by FeaturedManifestConfigurationTest via + // ProjectBuilder — verifying that here through `:outgoingVariants` triggers a known + // ConcurrentModificationException in AGP 9.1.0 when Android's per-variant configurations + // are iterated alongside our consumable one. + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private fun readManifest(): FeaturedManifest { + val file = projectDir.resolve("app/build/featured/featured-manifest.json") + assertTrue(file.exists(), "Expected featured-manifest.json at ${file.path}") + return FeaturedManifestJson.decodeFromString(file.readText()) + } + + private fun gradleRunner(): GradleRunner = + GradleRunner + .create() + .withProjectDir(projectDir) + .withPluginClasspath() + .forwardOutput() +} diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestMappingTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestMappingTest.kt new file mode 100644 index 0000000..200fd84 --- /dev/null +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestMappingTest.kt @@ -0,0 +1,250 @@ +package dev.androidbroadcast.featured.gradle.manifest + +import dev.androidbroadcast.featured.gradle.LocalFlagEntry +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class FeaturedManifestMappingTest { + // ── ValueType mapping ────────────────────────────────────────────────────── + + @Test + fun `Boolean type maps to BOOLEAN ValueType`() { + val entry = localEntry(key = "flag", type = "Boolean", defaultValue = "false") + val descriptor = entry.toFlagDescriptor() + assertEquals(ValueType.BOOLEAN, descriptor.valueType) + } + + @Test + fun `Int type maps to INT ValueType`() { + val entry = localEntry(key = "flag", type = "Int", defaultValue = "0") + val descriptor = entry.toFlagDescriptor() + assertEquals(ValueType.INT, descriptor.valueType) + } + + @Test + fun `Long type maps to LONG ValueType`() { + val entry = localEntry(key = "flag", type = "Long", defaultValue = "0") + val descriptor = entry.toFlagDescriptor() + assertEquals(ValueType.LONG, descriptor.valueType) + } + + @Test + fun `Float type maps to FLOAT ValueType`() { + val entry = localEntry(key = "flag", type = "Float", defaultValue = "1.5") + val descriptor = entry.toFlagDescriptor() + assertEquals(ValueType.FLOAT, descriptor.valueType) + } + + @Test + fun `Double type maps to DOUBLE ValueType`() { + val entry = localEntry(key = "flag", type = "Double", defaultValue = "3.14") + val descriptor = entry.toFlagDescriptor() + assertEquals(ValueType.DOUBLE, descriptor.valueType) + } + + @Test + fun `String type maps to STRING ValueType`() { + val entry = localEntry(key = "flag", type = "String", defaultValue = "\"hello\"") + val descriptor = entry.toFlagDescriptor() + assertEquals(ValueType.STRING, descriptor.valueType) + } + + // ── FlagKind mapping ─────────────────────────────────────────────────────── + + @Test + fun `local flagType maps to LOCAL FlagKind`() { + val entry = localEntry(key = "flag", type = "Boolean", defaultValue = "false", flagType = LocalFlagEntry.FLAG_TYPE_LOCAL) + assertEquals(FlagKind.LOCAL, entry.toFlagDescriptor().kind) + } + + @Test + fun `remote flagType maps to REMOTE FlagKind`() { + val entry = localEntry(key = "flag", type = "Boolean", defaultValue = "false", flagType = LocalFlagEntry.FLAG_TYPE_REMOTE) + assertEquals(FlagKind.REMOTE, entry.toFlagDescriptor().kind) + } + + // ── String default value unwrapping ──────────────────────────────────────── + + @Test + fun `String defaultValue with surrounding quotes is unwrapped`() { + val entry = localEntry(key = "greeting", type = "String", defaultValue = "\"hello\"") + val descriptor = entry.toFlagDescriptor() + assertEquals("hello", descriptor.defaultValue) + } + + @Test + fun `String defaultValue without surrounding quotes is kept as-is`() { + // ScanResultParser stores the raw value; this tests what happens with bare strings. + val entry = localEntry(key = "greeting", type = "String", defaultValue = "hello") + val descriptor = entry.toFlagDescriptor() + // removeSurrounding("\"") does nothing when the value does not start and end with " + assertEquals("hello", descriptor.defaultValue) + } + + // ── Enum mapping ────────────────────────────────────────────────────────── + + @Test + fun `enum entry maps to ENUM ValueType with enumTypeFqn and stripped constant name`() { + val entry = + LocalFlagEntry( + key = "checkout_variant", + defaultValue = "com.example.CheckoutVariant.FAST", + type = "com.example.CheckoutVariant", + moduleName = ":app", + propertyName = "checkoutVariant", + flagType = LocalFlagEntry.FLAG_TYPE_LOCAL, + ) + assertTrue(entry.isEnum, "Expected isEnum = true for FQN type") + val descriptor = entry.toFlagDescriptor() + assertEquals(ValueType.ENUM, descriptor.valueType) + assertEquals("com.example.CheckoutVariant", descriptor.enumTypeFqn) + // Only the constant name — not the FQN — is stored in defaultValue. + assertEquals("FAST", descriptor.defaultValue) + } + + @Test + fun `enum entry does not strip enumTypeFqn when isEnum is false`() { + // isEnum is computed as '.' in type — a type without dots is not an enum. + val entry = localEntry(key = "flag", type = "Boolean", defaultValue = "false") + assertNull(entry.toFlagDescriptor().enumTypeFqn) + } + + // ── Unknown type error ───────────────────────────────────────────────────── + + @Test + fun `unknown type throws IllegalStateException containing type and key`() { + val entry = localEntry(key = "my_date_flag", type = "Date", defaultValue = "2026-01-01") + val ex = assertFailsWith { entry.toFlagDescriptor() } + assertTrue(ex.message?.contains("Date") == true, "Error message must contain the type 'Date', got: ${ex.message}") + assertTrue(ex.message?.contains("my_date_flag") == true, "Error message must contain the key 'my_date_flag', got: ${ex.message}") + } + + // ── Optional metadata fields ─────────────────────────────────────────────── + + @Test + fun `null optional fields are passed through as null`() { + val entry = + LocalFlagEntry( + key = "flag", + defaultValue = "false", + type = "Boolean", + moduleName = ":app", + propertyName = "flag", + description = null, + category = null, + expiresAt = null, + ) + val descriptor = entry.toFlagDescriptor() + assertNull(descriptor.description) + assertNull(descriptor.category) + assertNull(descriptor.expiresAt) + } + + @Test + fun `non-null optional fields are preserved in FlagDescriptor`() { + val entry = + LocalFlagEntry( + key = "flag", + defaultValue = "false", + type = "Boolean", + moduleName = ":app", + propertyName = "flag", + description = "A useful flag", + category = "UI", + expiresAt = "2027-01-01", + ) + val descriptor = entry.toFlagDescriptor() + assertEquals("A useful flag", descriptor.description) + assertEquals("UI", descriptor.category) + assertEquals("2027-01-01", descriptor.expiresAt) + } + + // ── Non-ASCII key ────────────────────────────────────────────────────────── + + @Test + fun `non-ASCII key is passed through to FlagDescriptor unchanged`() { + // toCamelCase() splits on '_' and uppercases each word's first char. + // For "тёмная_тема": ["тёмная", "тема"] → "тёмная" + "Тема" = "тёмнаяТема" + val entry = + LocalFlagEntry( + key = "тёмная_тема", + defaultValue = "false", + type = "Boolean", + moduleName = ":app", + propertyName = + "тёмная_тема" + .split("_") + .mapIndexed { i, w -> + if (i == 0) w.lowercase() else w.replaceFirstChar { it.uppercase() } + }.joinToString(""), + ) + val descriptor = entry.toFlagDescriptor() + assertEquals("тёмная_тема", descriptor.key) + // propertyName is passed through as-is from the entry. + assertEquals("тёмнаяТема", descriptor.propertyName) + } + + // ── Pipe separator in String default value ───────────────────────────────── + + @Test + fun `pipe character in String default value is a known parser limitation`() { + // NOTE: ScanResultParser splits lines by '|' — strings whose value contains '|' break + // the pipe-delimited format and inflate the field count past the supported sizes + // (4 / 6 / 7 / 9). Lines that do not match a known field count are silently dropped + // (parseLine returns null). + // + // FlagContainer.string() wraps the default in escaped quotes when serialising, so the + // raw line for `string("my_flag", default = "a|b")` looks like: + // my_flag|"a|b"|String|:app|myFlag|local||| + // which splits into 10 parts instead of the expected 9 — the parser silently drops it. + // + // This test documents the limitation; a future minor PR may add `require('|' !in default)` + // to FlagContainer.string() to fail fast at configuration time instead of silently. + val rawLine = "my_flag|\"a|b\"|String|:app|myFlag|local|||" + val parts = rawLine.split("|") + assertEquals( + 10, + parts.size, + "A '|' inside defaultValue inflates the field count past 9; parser will return null and silently drop the entry", + ) + } + + // ── Same key in local and remote ─────────────────────────────────────────── + + @Test + fun `same key for local and remote entries produces distinct FlagDescriptors with different kinds`() { + // Conflict detection (which entry wins, deduplication) is handled in PR B. + // The mapper itself produces two FlagDescriptors and does not deduplicate. + val local = localEntry(key = "promo", type = "Boolean", defaultValue = "false", flagType = LocalFlagEntry.FLAG_TYPE_LOCAL) + val remote = localEntry(key = "promo", type = "Boolean", defaultValue = "false", flagType = LocalFlagEntry.FLAG_TYPE_REMOTE) + + val localDescriptor = local.toFlagDescriptor() + val remoteDescriptor = remote.toFlagDescriptor() + + assertEquals("promo", localDescriptor.key) + assertEquals("promo", remoteDescriptor.key) + assertEquals(FlagKind.LOCAL, localDescriptor.kind) + assertEquals(FlagKind.REMOTE, remoteDescriptor.kind) + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private fun localEntry( + key: String, + type: String, + defaultValue: String, + flagType: String = LocalFlagEntry.FLAG_TYPE_LOCAL, + propertyName: String = key, + ): LocalFlagEntry = + LocalFlagEntry( + key = key, + defaultValue = defaultValue, + type = type, + moduleName = ":app", + propertyName = propertyName, + flagType = flagType, + ) +} diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestSerializationTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestSerializationTest.kt new file mode 100644 index 0000000..3aef5b4 --- /dev/null +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestSerializationTest.kt @@ -0,0 +1,252 @@ +package dev.androidbroadcast.featured.gradle.manifest + +import kotlinx.serialization.SerializationException +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class FeaturedManifestSerializationTest { + @Test + fun `round-trip produces identical object`() { + val manifest = + FeaturedManifest( + schemaVersion = SCHEMA_VERSION, + modulePath = ":feature:checkout", + flags = + listOf( + FlagDescriptor( + key = "dark_mode", + propertyName = "darkMode", + kind = FlagKind.LOCAL, + valueType = ValueType.BOOLEAN, + defaultValue = "false", + ), + ), + ) + + val json = FeaturedManifestJson.encodeToString(manifest) + val decoded = FeaturedManifestJson.decodeFromString(json) + + assertEquals(manifest, decoded) + } + + @Test + fun `schemaVersion is present explicitly in JSON output`() { + val manifest = + FeaturedManifest( + schemaVersion = 1, + modulePath = ":", + flags = emptyList(), + ) + + val json = FeaturedManifestJson.encodeToString(manifest) + + assertTrue(json.contains("\"schemaVersion\""), "Expected 'schemaVersion' field in JSON") + assertTrue(json.contains("\"schemaVersion\": 1"), "Expected schemaVersion value 1 in JSON") + } + + @Test + fun `empty flags list serializes as empty array not omitted`() { + val manifest = + FeaturedManifest( + schemaVersion = SCHEMA_VERSION, + modulePath = ":", + flags = emptyList(), + ) + + val json = FeaturedManifestJson.encodeToString(manifest) + + // Must appear as "flags": [] not be absent + assertTrue(json.contains("\"flags\": []"), "Expected 'flags': [] in JSON, got:\n$json") + } + + @Test + fun `null optional fields are omitted from JSON`() { + val manifest = + FeaturedManifest( + schemaVersion = SCHEMA_VERSION, + modulePath = ":app", + flags = + listOf( + FlagDescriptor( + key = "feature", + propertyName = "feature", + kind = FlagKind.REMOTE, + valueType = ValueType.BOOLEAN, + defaultValue = "true", + enumTypeFqn = null, + description = null, + category = null, + expiresAt = null, + ), + ), + ) + + val json = FeaturedManifestJson.encodeToString(manifest) + + assertFalse(json.contains("enumTypeFqn"), "Null enumTypeFqn must be omitted from JSON") + assertFalse(json.contains("description"), "Null description must be omitted from JSON") + assertFalse(json.contains("category"), "Null category must be omitted from JSON") + assertFalse(json.contains("expiresAt"), "Null expiresAt must be omitted from JSON") + } + + @Test + fun `enum flag round-trip preserves enumTypeFqn`() { + val manifest = + FeaturedManifest( + schemaVersion = SCHEMA_VERSION, + modulePath = ":feature:checkout", + flags = + listOf( + FlagDescriptor( + key = "checkout_variant", + propertyName = "checkoutVariant", + kind = FlagKind.LOCAL, + valueType = ValueType.ENUM, + defaultValue = "LEGACY", + enumTypeFqn = "com.example.CheckoutVariant", + ), + ), + ) + + val json = FeaturedManifestJson.encodeToString(manifest) + val decoded = FeaturedManifestJson.decodeFromString(json) + + assertEquals("com.example.CheckoutVariant", decoded.flags.first().enumTypeFqn) + assertEquals("LEGACY", decoded.flags.first().defaultValue) + } + + @Test + fun `Float and Double valueTypes round-trip correctly`() { + val flags = + listOf( + FlagDescriptor( + key = "float_flag", + propertyName = "floatFlag", + kind = FlagKind.LOCAL, + valueType = ValueType.FLOAT, + defaultValue = "1.5", + ), + FlagDescriptor( + key = "double_flag", + propertyName = "doubleFlag", + kind = FlagKind.REMOTE, + valueType = ValueType.DOUBLE, + defaultValue = "3.14", + ), + ) + val manifest = FeaturedManifest(schemaVersion = SCHEMA_VERSION, modulePath = ":", flags = flags) + + val json = FeaturedManifestJson.encodeToString(manifest) + val decoded = FeaturedManifestJson.decodeFromString(json) + + assertEquals(ValueType.FLOAT, decoded.flags[0].valueType) + assertEquals(ValueType.DOUBLE, decoded.flags[1].valueType) + } + + @Test + fun `unknown JSON field during decode does not throw (forward-compatible)`() { + val json = + """ + { + "schemaVersion": 1, + "modulePath": ":", + "flags": [], + "unknownFutureField": "some value" + } + """.trimIndent() + + // Should not throw — ignoreUnknownKeys = true in FeaturedManifestJson + val manifest = FeaturedManifestJson.decodeFromString(json) + assertEquals(1, manifest.schemaVersion) + assertEquals(":", manifest.modulePath) + } + + @Test + fun `unknown enum variant in FlagKind throws SerializationException`() { + val json = + """ + { + "schemaVersion": 1, + "modulePath": ":", + "flags": [ + { + "key": "f", + "propertyName": "f", + "kind": "UNKNOWN_KIND", + "valueType": "BOOLEAN", + "defaultValue": "false" + } + ] + } + """.trimIndent() + + // Silent skip of unknown enum variants would be a silent data-loss bug. + // SerializationException is the expected behavior — fail fast. + assertFailsWith { + FeaturedManifestJson.decodeFromString(json) + } + } + + @Test + fun `unknown enum variant in ValueType throws SerializationException`() { + val json = + """ + { + "schemaVersion": 1, + "modulePath": ":", + "flags": [ + { + "key": "f", + "propertyName": "f", + "kind": "LOCAL", + "valueType": "UNKNOWN_TYPE", + "defaultValue": "false" + } + ] + } + """.trimIndent() + + assertFailsWith { + FeaturedManifestJson.decodeFromString(json) + } + } + + @Test + fun `all flag fields are preserved in round-trip`() { + val flag = + FlagDescriptor( + key = "my_flag", + propertyName = "myFlag", + kind = FlagKind.REMOTE, + valueType = ValueType.STRING, + defaultValue = "hello world", + enumTypeFqn = null, + description = "A test flag", + category = "test", + expiresAt = "2026-12-31", + ) + val manifest = FeaturedManifest(schemaVersion = SCHEMA_VERSION, modulePath = ":app", flags = listOf(flag)) + + val decoded = + FeaturedManifestJson.decodeFromString( + FeaturedManifestJson.encodeToString(manifest), + ) + + val decodedFlag = decoded.flags.first() + assertEquals(flag.key, decodedFlag.key) + assertEquals(flag.propertyName, decodedFlag.propertyName) + assertEquals(flag.kind, decodedFlag.kind) + assertEquals(flag.valueType, decodedFlag.valueType) + assertEquals(flag.defaultValue, decodedFlag.defaultValue) + assertNull(decodedFlag.enumTypeFqn) + assertEquals("A test flag", decodedFlag.description) + assertEquals("test", decodedFlag.category) + assertEquals("2026-12-31", decodedFlag.expiresAt) + } +} diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/GenerateFeaturedManifestTaskRegistrationTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/GenerateFeaturedManifestTaskRegistrationTest.kt new file mode 100644 index 0000000..cd56b1b --- /dev/null +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/GenerateFeaturedManifestTaskRegistrationTest.kt @@ -0,0 +1,103 @@ +package dev.androidbroadcast.featured.gradle.manifest + +import dev.androidbroadcast.featured.gradle.RESOLVE_FLAGS_TASK_NAME +import org.gradle.testfixtures.ProjectBuilder +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class GenerateFeaturedManifestTaskRegistrationTest { + @Test + fun `plugin registers generateFeaturedManifest task`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured") + + assertTrue( + project.tasks.names.contains(GENERATE_FEATURED_MANIFEST_TASK_NAME), + "Expected '$GENERATE_FEATURED_MANIFEST_TASK_NAME' task to be registered by the plugin", + ) + } + + @Test + fun `generateFeaturedManifest task is of correct type`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured") + + val task = project.tasks.findByName(GENERATE_FEATURED_MANIFEST_TASK_NAME) + assertNotNull(task) + assertTrue( + task is GenerateFeaturedManifestTask, + "Expected task type GenerateFeaturedManifestTask but was ${task::class.simpleName}", + ) + } + + @Test + fun `generateFeaturedManifest task is in featured group`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured") + + val task = project.tasks.findByName(GENERATE_FEATURED_MANIFEST_TASK_NAME) + assertNotNull(task) + assertEquals( + "featured", + task.group, + "Expected task group 'featured' but was '${task.group}'", + ) + } + + @Test + fun `generateFeaturedManifest task output path follows convention`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured") + + val task = project.tasks.findByName(GENERATE_FEATURED_MANIFEST_TASK_NAME) as? GenerateFeaturedManifestTask + assertNotNull(task) + val outputPath = + task.outputFile + .get() + .asFile.path + assertTrue( + outputPath.endsWith("featured/featured-manifest.json"), + "Expected outputFile path to end with 'featured/featured-manifest.json', got: $outputPath", + ) + } + + @Test + fun `generate fails with IllegalArgumentException when modulePath does not start with colon`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured") + + val emptyFlags = File.createTempFile("flags", ".txt").apply { deleteOnExit() } + val task = project.tasks.findByName(GENERATE_FEATURED_MANIFEST_TASK_NAME) as GenerateFeaturedManifestTask + task.modulePath.set("not-a-gradle-path") + task.flagsFile.set(emptyFlags) + + val ex = assertFailsWith { task.generate() } + assertTrue( + ex.message?.contains("not-a-gradle-path") == true, + "Expected error message to name the offending path, got: ${ex.message}", + ) + assertTrue( + ex.message?.contains(":") == true, + "Expected error message to mention the required ':' prefix, got: ${ex.message}", + ) + } + + @Test + fun `generateFeaturedManifest task depends on resolveFeatureFlags`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured") + + val manifestTask = project.tasks.findByName(GENERATE_FEATURED_MANIFEST_TASK_NAME) + assertNotNull(manifestTask) + val resolveTask = project.tasks.findByName(RESOLVE_FLAGS_TASK_NAME) + assertNotNull(resolveTask) + assertTrue( + manifestTask.taskDependencies.getDependencies(manifestTask).contains(resolveTask), + "Expected '$GENERATE_FEATURED_MANIFEST_TASK_NAME' to depend on '$RESOLVE_FLAGS_TASK_NAME'", + ) + } +} diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/TestFixtureSupport.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/TestFixtureSupport.kt new file mode 100644 index 0000000..17fc588 --- /dev/null +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/TestFixtureSupport.kt @@ -0,0 +1,50 @@ +package dev.androidbroadcast.featured.gradle.manifest + +import java.io.File + +// Shared helpers for the manifest test suite. Each integration / TestKit test copies a +// pinned fixture directory into a per-test temp folder so that test runs do not pollute +// the source tree and remain isolated from each other. + +/** + * Copies the fixture directory named [fixtureName] from `featured-gradle-plugin/src/test/fixtures/` + * into [dest]. Non-file entries are skipped. The `.gitkeep` marker files used to keep otherwise + * empty fixture directories in git are filtered out — they are not part of the project under test. + */ +internal fun copyManifestFixture( + fixtureName: String, + dest: File, +) { + val source = fixtureSourceDir(fixtureName) + source + .walkTopDown() + .filter { it.isFile && it.name != ".gitkeep" } + .forEach { file -> + val target = dest.resolve(file.relativeTo(source)) + target.parentFile?.mkdirs() + file.copyTo(target, overwrite = true) + } +} + +private fun fixtureSourceDir(fixtureName: String): File { + val moduleDir = File(System.getProperty("user.dir")) + val candidate = moduleDir.resolve("src/test/fixtures/$fixtureName") + require(candidate.isDirectory) { + "Fixture directory not found at ${candidate.absolutePath}. " + + "Expected it relative to module project dir: ${moduleDir.absolutePath}" + } + return candidate +} + +/** + * Returns the Android SDK directory from `ANDROID_HOME` or `ANDROID_SDK_ROOT`, or null when + * neither is set or the path is not a directory. Used by integration tests that need an + * Android SDK to run the Android Gradle plugin; without it they skip via JUnit `Assume`. + */ +internal fun androidSdkDirOrNull(): File? { + val path = + System.getenv("ANDROID_HOME")?.takeIf { it.isNotBlank() } + ?: System.getenv("ANDROID_SDK_ROOT")?.takeIf { it.isNotBlank() } + ?: return null + return File(path).takeIf { it.isDirectory } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 03a5a0f..c3d1ca6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,6 +18,7 @@ firebaseBom = "34.11.0" junit = "4.13.2" kotlin = "2.3.10" kotlinx-coroutines = "1.10.2" +kotlinx-serialization = "1.11.0" kover = "0.9.8" material = "1.13.0" mockk = "1.14.9" @@ -58,6 +59,7 @@ kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-c kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-playServices = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-play-services", version.ref = "kotlinx-coroutines" } kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } firebase-analytics = { module = "com.google.firebase:firebase-analytics" } @@ -85,6 +87,7 @@ composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMul composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } skie = { id = "co.touchlab.skie", version.ref = "skie" } spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } From 30cb9d07a75945b0f5a188b2d0d9fb0072e28982 Mon Sep 17 00:00:00 2001 From: Kirill Rozov Date: Tue, 19 May 2026 13:47:06 +0300 Subject: [PATCH 08/15] Add aggregator plugin and GeneratedFeaturedRegistry codegen (#198) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add aggregator plugin and GeneratedFeaturedRegistry codegen Introduce dev.androidbroadcast.featured.application Gradle plugin that aggregates featured-manifest.json artifacts from project dependencies declared via featuredAggregation(project(...)) and generates a unified object GeneratedFeaturedRegistry { val all: List> } in build/generated/featured/commonMain/. The aggregator reuses the FEATURED_MANIFEST_USAGE / schemaMajorAttr contract published by dev.androidbroadcast.featured (PR A, schema v1) through a resolvable featuredAggregationClasspath configuration. generateFeaturedRegistry is @CacheableTask with stable (modulePath, key) sort order; duplicate keys across the aggregated graph fail the build naming both module paths. Co-Authored-By: Claude Opus 4.7 * Harden aggregator against malformed and hostile manifests Apply finalize Round 1 findings across code-reviewer, /simplify, the pr-review-toolkit trio, and the architecture/build/security experts: - Escape newline / carriage-return / tab in escapeKotlinString so a description or STRING default with literal whitespace cannot break the generated source. - Validate enumTypeFqn against Kotlin FQN grammar and ENUM defaultValue against the Kotlin identifier grammar before codegen. A hostile project dependency can no longer inject Kotlin source through the featured-manifest.json payload that would execute on the next compile in the consumer module. - Sort manifests by modulePath inside validateUniqueKeys so the duplicate-key error lists origins in a deterministic order regardless of Gradle resolution order. - Wrap manifest parse and read errors with the offending file path so diagnostics name which artifact failed. - Thread the offending key + module path into the requireNotNull error raised when an ENUM descriptor is missing its enumTypeFqn. - Validate outputPackage against a Kotlin package-name regex at task execution time instead of waiting for the consumer compile to fail. - Narrow FeaturedApplicationPlugin to internal — Gradle still resolves the descriptor by FQN via reflection, and the consumer-facing surface is the plugin id alone. - Remove the redundant pre-sort and dead Origin class in the task; the generator does its own (modulePath, key) sort, and the validator now owns ordering for its own error path. - Drop a stale inline comment that paraphrased the generator KDoc. Tests: - Newline, carriage return, and tab escape tests for STRING defaults and descriptions. - Parse-error test asserting the wrapper carries the file path. - Descriptor-integrity tests covering enumTypeFqn / defaultValue injection attempts (semicolon, angle bracket, parenthesis, brace, whitespace, Unicode line separator, missing FQN). - Lazy-realization test mirroring the producer-side pattern. :featured-gradle-plugin:check — BUILD SUCCESSFUL, 266 tests, 0 failures. Co-Authored-By: Claude Opus 4.7 * Address review: primitive default injection + enum classpath docs cubic #1 and Copilot #3: extend validateFlagDescriptorIntegrity from ENUM-only to an exhaustive when over all seven ValueTypes. Boolean, Int, Long, Float, and Double defaultValue strings are now validated against their respective Kotlin literal grammars before reaching the codegen. A malicious project dependency can no longer smuggle Kotlin through a primitive default like "0.also { ... }" for an Int flag or "false; init { ... }; private val x = true" for a Boolean. STRING is the only remaining unchecked branch — already neutralised by escapeKotlinString. Copilot #4: document that featuredAggregation(project(...)) resolves only the featured-manifest variant, not the producer's main runtime. Modules with enum flags additionally require implementation(project) in the consumer for the enum class to be on the compile classpath. Plugin KDoc and the CHANGELOG entry both call this out. Copilot #5: fix GeneratedFeaturedRegistryGenerator KDoc that claimed to import enum FQNs. The generator imports only ConfigParam and references enum types inline by FQN; KDoc now matches. Tests: 10 new primitive-literal cases — true/false pass, injection shapes throw, negative ints pass, Long max pass, scientific-notation double pass, brace/method-call shapes throw. :featured-gradle-plugin:check — BUILD SUCCESSFUL, 0 failures. Co-Authored-By: Claude Opus 4.7 --------- Co-authored-by: Claude Opus 4.7 --- CHANGELOG.md | 1 + featured-gradle-plugin/build.gradle.kts | 4 + .../gradle/FeaturedApplicationPlugin.kt | 119 ++++++ .../gradle/aggregation/AggregationContract.kt | 36 ++ .../GenerateFeaturedRegistryTask.kt | 233 +++++++++++ .../GeneratedFeaturedRegistryGenerator.kt | 164 ++++++++ .../app/build.gradle.kts | 16 + .../app/src/main/AndroidManifest.xml | 2 + .../build.gradle.kts | 1 + .../feature-checkout/build.gradle.kts | 17 + .../src/main/AndroidManifest.xml | 2 + .../feature-profile/build.gradle.kts | 19 + .../src/main/AndroidManifest.xml | 2 + .../gradle.properties | 3 + .../settings.gradle.kts | 33 ++ .../FeaturedAggregationConfigurationTest.kt | 102 +++++ ...turedAggregationDescriptorIntegrityTest.kt | 217 ++++++++++ .../FeaturedAggregationDuplicateKeyTest.kt | 137 +++++++ .../FeaturedAggregationIntegrationTest.kt | 198 +++++++++ .../FeaturedAggregationParseErrorTest.kt | 46 +++ ...ateFeaturedRegistryTaskRegistrationTest.kt | 90 +++++ .../GeneratedFeaturedRegistryGeneratorTest.kt | 382 ++++++++++++++++++ 22 files changed, 1824 insertions(+) create mode 100644 featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedApplicationPlugin.kt create mode 100644 featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/AggregationContract.kt create mode 100644 featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GenerateFeaturedRegistryTask.kt create mode 100644 featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GeneratedFeaturedRegistryGenerator.kt create mode 100644 featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/app/build.gradle.kts create mode 100644 featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/app/src/main/AndroidManifest.xml create mode 100644 featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/build.gradle.kts create mode 100644 featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-checkout/build.gradle.kts create mode 100644 featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-checkout/src/main/AndroidManifest.xml create mode 100644 featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-profile/build.gradle.kts create mode 100644 featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-profile/src/main/AndroidManifest.xml create mode 100644 featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/gradle.properties create mode 100644 featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/settings.gradle.kts create mode 100644 featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationConfigurationTest.kt create mode 100644 featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationDescriptorIntegrityTest.kt create mode 100644 featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationDuplicateKeyTest.kt create mode 100644 featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationIntegrationTest.kt create mode 100644 featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationParseErrorTest.kt create mode 100644 featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GenerateFeaturedRegistryTaskRegistrationTest.kt create mode 100644 featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GeneratedFeaturedRegistryGeneratorTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index cfcb5e7..7fe9f6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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> }` 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(...)`. ## [1.0.0-Beta1] - 2026-05-17 diff --git a/featured-gradle-plugin/build.gradle.kts b/featured-gradle-plugin/build.gradle.kts index 36b432e..f4628d8 100644 --- a/featured-gradle-plugin/build.gradle.kts +++ b/featured-gradle-plugin/build.gradle.kts @@ -18,6 +18,10 @@ gradlePlugin { id = "dev.androidbroadcast.featured" implementationClass = "dev.androidbroadcast.featured.gradle.FeaturedPlugin" } + create("featuredApplication") { + id = "dev.androidbroadcast.featured.application" + implementationClass = "dev.androidbroadcast.featured.gradle.FeaturedApplicationPlugin" + } } } diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedApplicationPlugin.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedApplicationPlugin.kt new file mode 100644 index 0000000..8b17388 --- /dev/null +++ b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedApplicationPlugin.kt @@ -0,0 +1,119 @@ +package dev.androidbroadcast.featured.gradle + +import dev.androidbroadcast.featured.gradle.aggregation.FEATURED_AGGREGATION_CLASSPATH_CONFIGURATION_NAME +import dev.androidbroadcast.featured.gradle.aggregation.FEATURED_AGGREGATION_CONFIGURATION_NAME +import dev.androidbroadcast.featured.gradle.aggregation.FEATURED_REGISTRY_OBJECT +import dev.androidbroadcast.featured.gradle.aggregation.FEATURED_REGISTRY_PACKAGE +import dev.androidbroadcast.featured.gradle.aggregation.GENERATE_FEATURED_REGISTRY_TASK_NAME +import dev.androidbroadcast.featured.gradle.aggregation.GenerateFeaturedRegistryTask +import dev.androidbroadcast.featured.gradle.manifest.FEATURED_MANIFEST_USAGE +import dev.androidbroadcast.featured.gradle.manifest.SCHEMA_VERSION +import dev.androidbroadcast.featured.gradle.manifest.schemaMajorAttr +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.attributes.Usage + +/** + * Gradle plugin ID: `dev.androidbroadcast.featured.application`. + * + * Aggregates `featured-manifest.json` artifacts from all project dependencies declared via + * `featuredAggregation(project(...))` and generates a unified + * `object GeneratedFeaturedRegistry { val all: List> }` Kotlin source file. + * + * Apply this plugin alongside `dev.androidbroadcast.featured` in the application or aggregator + * module: + * ```kotlin + * plugins { + * id("dev.androidbroadcast.featured") + * id("dev.androidbroadcast.featured.application") + * } + * + * dependencies { + * featuredAggregation(project(":feature:checkout")) + * featuredAggregation(project(":feature:profile")) + * } + * ``` + * + * The generated file is written to + * `build/generated/featured/commonMain/GeneratedFeaturedRegistry.kt`. + * Wire the output directory into your source set manually — the plugin does not auto-wire + * to avoid assumptions about whether the consuming module is KMP, AGP, or plain JVM: + * ```kotlin + * kotlin.sourceSets.getByName("commonMain").kotlin.srcDir( + * tasks.named("generateFeaturedRegistry").map { it.outputs.files.singleFile.parentFile } + * ) + * ``` + * + * **Enum flag classpath requirement.** A `featuredAggregation(project(":feature:foo"))` dependency + * resolves only the `featured-manifest` Gradle variant — it does NOT put the producer's enum types + * on the consumer's compile classpath. If `:feature:foo` declares an `enum` flag whose type lives + * in `:feature:foo`'s source set, the application module must add a regular runtime dependency on + * the same module so the enum class is visible at compile time: + * ```kotlin + * dependencies { + * featuredAggregation(project(":feature:foo")) + * implementation(project(":feature:foo")) // required for enum flag types + * } + * ``` + * For modules that declare only primitive flags (Boolean / Int / Long / Float / Double / String), + * the `featuredAggregation` line alone is sufficient. + * + * Min Gradle version: 8.5+ (`configurations.dependencyScope()` / `.resolvable()` API). + */ +@Suppress("UnstableApiUsage") +internal class FeaturedApplicationPlugin : Plugin { + override fun apply(target: Project) { + // Register the schemaMajorAttr in the project's attribute schema. This is idempotent — + // if FeaturedPlugin is also applied it calls the same registration first. + target.dependencies.attributesSchema.attribute(schemaMajorAttr) + + // User-facing declarable scope: consumers add project() dependencies here. + val declarable = + target.configurations.dependencyScope( + FEATURED_AGGREGATION_CONFIGURATION_NAME, + ) { cfg -> + cfg.description = + "Project dependencies whose featured-manifest.json should be aggregated into GeneratedFeaturedRegistry." + } + + // Internal resolvable classpath that carries the attribute contract used by Gradle's + // variant selection to match the `featuredManifest` consumable configuration published + // by each producer module applying `dev.androidbroadcast.featured`. + val classpath = + target.configurations.resolvable( + FEATURED_AGGREGATION_CLASSPATH_CONFIGURATION_NAME, + ) { cfg -> + cfg.description = + "Internal classpath resolving featured-manifest.json artifacts from featuredAggregation." + cfg.extendsFrom(declarable.get()) + cfg.attributes { attrs -> + attrs.attribute( + Usage.USAGE_ATTRIBUTE, + target.objects.named(Usage::class.java, FEATURED_MANIFEST_USAGE), + ) + // Mirror the schema-major attribute declared on the producer side so that Gradle's + // variant selection picks exactly the schema-v1 manifests. + attrs.attribute(schemaMajorAttr, SCHEMA_VERSION) + } + } + + target.tasks.register( + GENERATE_FEATURED_REGISTRY_TASK_NAME, + GenerateFeaturedRegistryTask::class.java, + ) { task -> + task.group = "featured" + task.description = + "Aggregates featured-manifest.json artifacts and generates GeneratedFeaturedRegistry.kt." + // Lazy artifact view — resolved at execution time, CC-compatible. + task.manifestFiles.from( + classpath.map { it.incoming.artifactView { view -> view.isLenient = false }.files }, + ) + task.outputPackage.set(FEATURED_REGISTRY_PACKAGE) + task.outputFile.convention( + target.layout.buildDirectory.file( + "generated/featured/commonMain/${FEATURED_REGISTRY_OBJECT}.kt", + ), + ) + } + } +} diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/AggregationContract.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/AggregationContract.kt new file mode 100644 index 0000000..4207435 --- /dev/null +++ b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/AggregationContract.kt @@ -0,0 +1,36 @@ +package dev.androidbroadcast.featured.gradle.aggregation + +/** + * Name of the user-facing declarable Gradle configuration. + * Consumers add dependencies here via `featuredAggregation(project(...))`. + * Used by [FeaturedApplicationPlugin] to create the dependency scope. + */ +internal const val FEATURED_AGGREGATION_CONFIGURATION_NAME = "featuredAggregation" + +/** + * Name of the internal resolvable Gradle configuration. + * Extends [FEATURED_AGGREGATION_CONFIGURATION_NAME] and carries the attribute contract + * (`Usage = "featured-manifest"`, `schema-major = 1`) that Gradle uses to select the + * `featuredManifest` outgoing variant from each producer module. + */ +internal const val FEATURED_AGGREGATION_CLASSPATH_CONFIGURATION_NAME = "featuredAggregationClasspath" + +/** + * Task name registered by [FeaturedApplicationPlugin]. + * Running `./gradlew generateFeaturedRegistry` collects all manifests and writes the + * generated Kotlin source to the output file. + */ +internal const val GENERATE_FEATURED_REGISTRY_TASK_NAME = "generateFeaturedRegistry" + +/** + * Package name emitted at the top of the generated `GeneratedFeaturedRegistry.kt` file. + * Matches the package used by other Featured-generated sources in `commonMain`. + */ +internal const val FEATURED_REGISTRY_PACKAGE = "dev.androidbroadcast.featured.generated" + +/** + * Simple name of the generated Kotlin object and the output file (without `.kt` extension). + * Used both as the object identifier in the generated source and as the output filename by + * [GenerateFeaturedRegistryTask]. + */ +internal const val FEATURED_REGISTRY_OBJECT = "GeneratedFeaturedRegistry" diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GenerateFeaturedRegistryTask.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GenerateFeaturedRegistryTask.kt new file mode 100644 index 0000000..87acb53 --- /dev/null +++ b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GenerateFeaturedRegistryTask.kt @@ -0,0 +1,233 @@ +package dev.androidbroadcast.featured.gradle.aggregation + +import dev.androidbroadcast.featured.gradle.manifest.FeaturedManifest +import dev.androidbroadcast.featured.gradle.manifest.FeaturedManifestJson +import dev.androidbroadcast.featured.gradle.manifest.ValueType +import kotlinx.serialization.decodeFromString +import org.gradle.api.DefaultTask +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction + +private val PACKAGE_NAME_REGEX = Regex("[a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*") + +// Accepted grammar for ENUM descriptor fields interpolated verbatim into generated Kotlin source. +// Untrusted manifest content from a malicious project dependency can inject Kotlin source via +// ENUM FQN or constant name — we reject anything that does not match before calling the generator. +private val KOTLIN_FQN_REGEX = Regex("[A-Za-z_][A-Za-z0-9_]*(\\.[A-Za-z_][A-Za-z0-9_]*)*") +private val KOTLIN_IDENTIFIER_REGEX = Regex("[A-Za-z_][A-Za-z0-9_]*") + +// Accepted grammars for primitive defaultValue fields interpolated verbatim into the generated +// Kotlin source. Malicious manifests can embed arbitrary Kotlin by supplying e.g. an INT value +// that contains method-call suffixes or a BOOLEAN value with an extra statement appended. +// Each regex matches only the literal forms that Kotlin accepts for the respective numeric type. +private val BOOLEAN_LITERAL_REGEX = Regex("true|false") +private val INT_LITERAL_REGEX = Regex("-?\\d+") +private val LONG_LITERAL_REGEX = Regex("-?\\d+") +private val FLOAT_LITERAL_REGEX = Regex("-?\\d+(\\.\\d+)?([eE]-?\\d+)?") +private val DOUBLE_LITERAL_REGEX = Regex("-?\\d+(\\.\\d+)?([eE]-?\\d+)?") + +/** + * Aggregates `featured-manifest.json` files from all project dependencies declared via + * `featuredAggregation(...)` and generates `GeneratedFeaturedRegistry.kt`. + * + * Registered by [FeaturedApplicationPlugin] under the name `generateFeaturedRegistry`. + * + * Validation: duplicate flag keys across modules (including LOCAL + REMOTE of the same module) + * are rejected with an [IllegalStateException] naming both conflicting module paths. + */ +@CacheableTask +internal abstract class GenerateFeaturedRegistryTask : DefaultTask() { + /** + * The set of `featured-manifest.json` files resolved from `featuredAggregationClasspath`. + * + * [PathSensitivity.NONE] is used because only the file content matters for cache-key + * computation — the artifact path varies across machines and build cache entries. + */ + @get:InputFiles + @get:PathSensitive(PathSensitivity.NONE) + abstract val manifestFiles: ConfigurableFileCollection + + /** + * Package name written to the top of the generated source file. + * Defaults to [FEATURED_REGISTRY_PACKAGE]. + */ + @get:Input + abstract val outputPackage: Property + + /** + * Destination for the generated `GeneratedFeaturedRegistry.kt` source file. + * Convention: `build/generated/featured/commonMain/GeneratedFeaturedRegistry.kt`. + */ + @get:OutputFile + abstract val outputFile: RegularFileProperty + + @TaskAction + fun generate() { + val pkg = outputPackage.get() + require(PACKAGE_NAME_REGEX.matches(pkg)) { + "outputPackage '$pkg' is not a valid Kotlin package name." + } + + val manifests = + manifestFiles.files + .map { file -> + try { + FeaturedManifestJson.decodeFromString(file.readText()) + } catch (e: Exception) { + throw IllegalStateException( + "Failed to read or parse Featured manifest at '${file.path}': ${e.message}", + e, + ) + } + } + + validateUniqueKeys(manifests) + validateFlagDescriptorIntegrity(manifests) + + val source = + GeneratedFeaturedRegistryGenerator.generate( + manifests = manifests, + packageName = pkg, + ) + + val outFile = outputFile.get().asFile + outFile.parentFile.mkdirs() + outFile.writeText(source) + + val totalFlags = manifests.sumOf { it.flags.size } + logger.lifecycle( + "[featured] Generated registry with $totalFlags flag(s) from ${manifests.size} module(s) → ${outFile.path}", + ) + } +} + +/** + * Validates that no two [FlagDescriptor][dev.androidbroadcast.featured.gradle.manifest.FlagDescriptor] + * entries across all [manifests] share the same key. + * + * A flag declared in both `localFlags` and `remoteFlags` of the same module is treated as a + * duplicate because each key produces exactly one `ConfigParam` in the registry. + * + * All duplicate keys are collected and reported in a single [IllegalStateException] so that + * every conflict is visible without requiring repeated build invocations. Each origin includes + * both the module path and the [FlagKind] so same-module LOCAL/REMOTE collisions are + * distinguishable from cross-module collisions. + * + * Manifests are sorted by [FeaturedManifest.modulePath] internally before processing so that + * the duplicate error message lists origins in a deterministic order regardless of the order + * in which Gradle resolves manifest artifacts. + * + * @throws IllegalStateException listing every duplicate key and all conflicting origins. + */ +internal fun validateUniqueKeys(manifests: List) { + val triples = + manifests + .sortedBy { it.modulePath } + .flatMap { manifest -> + manifest.flags.map { flag -> Triple(flag.key, manifest.modulePath, flag.kind) } + } + + // Collect every key that appears more than once, together with all its origins. + val duplicates = + triples + .groupBy { (key, _, _) -> key } + .filter { (_, entries) -> entries.size > 1 } + + if (duplicates.isEmpty()) return + + val message = + buildString { + appendLine("Duplicate flag keys detected in aggregated Featured manifests:") + duplicates.forEach { (key, entries) -> + val origins = entries.joinToString(", ") { (_, path, kind) -> "'$path' ($kind)" } + appendLine(" - '$key': declared in $origins") + } + } + throw IllegalStateException(message.trimEnd()) +} + +/** + * Validates the integrity of all flag descriptors in [manifests] against Kotlin literal grammars + * before passing them to the code generator. + * + * Threat model: a malicious build-script author of a project dependency declared via + * `featuredAggregation(project(":evil"))` controls the contents of `featured-manifest.json` + * and can supply arbitrary strings for `enumTypeFqn` and `defaultValue`. These fields are + * interpolated verbatim into the generated `.kt` file, so injecting `;`, `{`, `(`, or similar + * characters produces syntactically valid Kotlin with arbitrary code that executes during the + * consuming project's `:compileKotlin`. + * + * We validate against Kotlin grammar here — single source of truth in the task — so the + * generator can never emit unintended syntax regardless of what arrives in the manifest. + * + * @throws IllegalArgumentException when any flag has an invalid [defaultValue] (or, for ENUM, + * an invalid [enumTypeFqn]), naming the offending key and module in the message. + */ +internal fun validateFlagDescriptorIntegrity(manifests: List) { + manifests.forEach { manifest -> + manifest.flags.forEach { flag -> + when (flag.valueType) { + ValueType.BOOLEAN -> { + require(BOOLEAN_LITERAL_REGEX.matches(flag.defaultValue)) { + "Invalid Boolean defaultValue '${flag.defaultValue}' for flag '${flag.key}' " + + "in module '${manifest.modulePath}': must be 'true' or 'false'." + } + } + + ValueType.INT -> { + require(INT_LITERAL_REGEX.matches(flag.defaultValue)) { + "Invalid Int defaultValue '${flag.defaultValue}' for flag '${flag.key}' " + + "in module '${manifest.modulePath}': must be an integer literal (digits, optional leading minus)." + } + } + + ValueType.LONG -> { + require(LONG_LITERAL_REGEX.matches(flag.defaultValue)) { + "Invalid Long defaultValue '${flag.defaultValue}' for flag '${flag.key}' " + + "in module '${manifest.modulePath}': must be an integer literal (digits, optional leading minus)." + } + } + + ValueType.FLOAT -> { + require(FLOAT_LITERAL_REGEX.matches(flag.defaultValue)) { + "Invalid Float defaultValue '${flag.defaultValue}' for flag '${flag.key}' " + + "in module '${manifest.modulePath}': must be a numeric literal (digits, optional decimal and exponent)." + } + } + + ValueType.DOUBLE -> { + require(DOUBLE_LITERAL_REGEX.matches(flag.defaultValue)) { + "Invalid Double defaultValue '${flag.defaultValue}' for flag '${flag.key}' " + + "in module '${manifest.modulePath}': must be a numeric literal (digits, optional decimal and exponent)." + } + } + + ValueType.STRING -> { + // STRING values are escaped via escapeKotlinString in the generator — no validation needed. + } + + ValueType.ENUM -> { + requireNotNull(flag.enumTypeFqn) { + "enumTypeFqn must not be null for ENUM flag '${flag.key}' in module '${manifest.modulePath}'." + } + require(KOTLIN_FQN_REGEX.matches(flag.enumTypeFqn)) { + "Invalid enumTypeFqn '${flag.enumTypeFqn}' for flag '${flag.key}' in module '${manifest.modulePath}': " + + "must be a valid Kotlin fully-qualified name." + } + require(KOTLIN_IDENTIFIER_REGEX.matches(flag.defaultValue)) { + "Invalid ENUM defaultValue '${flag.defaultValue}' for flag '${flag.key}' in module '${manifest.modulePath}': " + + "must be a valid Kotlin identifier." + } + } + } + } + } +} diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GeneratedFeaturedRegistryGenerator.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GeneratedFeaturedRegistryGenerator.kt new file mode 100644 index 0000000..b23888f --- /dev/null +++ b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GeneratedFeaturedRegistryGenerator.kt @@ -0,0 +1,164 @@ +package dev.androidbroadcast.featured.gradle.aggregation + +import dev.androidbroadcast.featured.gradle.manifest.FeaturedManifest +import dev.androidbroadcast.featured.gradle.manifest.FlagDescriptor +import dev.androidbroadcast.featured.gradle.manifest.ValueType + +/** + * Generates `GeneratedFeaturedRegistry.kt` source from the aggregated [FeaturedManifest] list. + * + * KMP-safe: imports only `dev.androidbroadcast.featured.ConfigParam`. Enum types are referenced + * inline by their fully-qualified name in both the `ConfigParam<...>` type argument and the + * `defaultValue = ...` expression — no separate enum imports are emitted. + * + * Output determinism: descriptors are sorted by `(modulePath, key)` over the flattened + * list before generation, so the output is identical regardless of the order in which + * Gradle resolves the manifest artifacts. + */ +internal object GeneratedFeaturedRegistryGenerator { + private const val CONFIG_PARAM_IMPORT = "dev.androidbroadcast.featured.ConfigParam" + + /** + * Generates the full `GeneratedFeaturedRegistry.kt` source text. + * + * @param manifests Aggregated manifests from all producer modules. + * @param packageName Package declared at the top of the generated file. + * @return Complete Kotlin source as a single [String]. + */ + fun generate( + manifests: List, + packageName: String, + ): String { + val sorted = + manifests + .flatMap { manifest -> manifest.flags.map { flag -> manifest.modulePath to flag } } + .sortedWith(compareBy({ it.first }, { it.second.key })) + + return buildString { + appendLine("// Auto-generated by Featured Gradle Plugin — do not edit manually.") + appendLine("package $packageName") + appendLine() + appendLine("import $CONFIG_PARAM_IMPORT") + appendLine() + appendLine("public object $FEATURED_REGISTRY_OBJECT {") + if (sorted.isEmpty()) { + appendLine(" public val all: List> = emptyList()") + } else { + appendLine(" public val all: List> = listOf(") + sorted.forEach { (modulePath, descriptor) -> + if (descriptor.valueType == ValueType.ENUM) { + requireNotNull(descriptor.enumTypeFqn) { + "enumTypeFqn must be non-null for ENUM flag '${descriptor.key}' in module '$modulePath'" + } + } + val typeArg = descriptor.valueType.toKotlinTypeName(descriptor.enumTypeFqn) + val defaultLiteral = descriptor.toDefaultLiteral() + val args = + buildList { + add("key = \"${escapeKotlinString(descriptor.key)}\"") + add("defaultValue = $defaultLiteral") + if (descriptor.description != null) add("description = \"${escapeKotlinString(descriptor.description)}\"") + if (descriptor.category != null) add("category = \"${escapeKotlinString(descriptor.category)}\"") + } + // Kotlin accepts trailing commas in listOf() — always emit one for uniform diffs. + appendLine(" ConfigParam<$typeArg>(${args.joinToString(", ")}),") + } + appendLine(" )") + } + append("}") + } + } +} + +/** + * Maps this [ValueType] to the Kotlin type name used in the `ConfigParam` type argument. + * + * For [ValueType.ENUM], [enumTypeFqn] must be non-null; it is used as the full type reference. + */ +private fun ValueType.toKotlinTypeName(enumTypeFqn: String?): String = + when (this) { + ValueType.BOOLEAN -> { + "Boolean" + } + + ValueType.INT -> { + "Int" + } + + ValueType.LONG -> { + "Long" + } + + ValueType.FLOAT -> { + "Float" + } + + ValueType.DOUBLE -> { + "Double" + } + + ValueType.STRING -> { + "String" + } + + ValueType.ENUM -> { + requireNotNull(enumTypeFqn) { + "enumTypeFqn must be non-null for ValueType.ENUM" + } + } + } + +/** + * Escapes a bare string value so it is safe to embed inside a Kotlin double-quoted string literal. + * + * Escape order matters: `\` must be processed first to avoid double-escaping characters + * introduced by subsequent replacements. + * + * - `\` → `\\` (backslash) + * - `"` → `\"` (double-quote) + * - `$` → `${'$'}` (prevents Kotlin string-template interpolation in the generated source) + * - `\n` → `\\n` (newline) + * - `\r` → `\\r` (carriage return) + * - `\t` → `\\t` (tab) + */ +private fun escapeKotlinString(value: String): String = + value + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("$", "\${'\$'}") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + +/** + * Produces the Kotlin literal for `defaultValue = ...` in the generated `ConfigParam` call. + * + * STRING: producer stores bare value (surrounding quotes already stripped); re-wrap and + * escape via [escapeKotlinString]. + * LONG: append `L` suffix. + * FLOAT: append `f` suffix. + * ENUM: rebuild as `enumTypeFqn.CONSTANT_NAME`. + * BOOLEAN, INT, DOUBLE: emit raw. + */ +private fun FlagDescriptor.toDefaultLiteral(): String = + when (valueType) { + ValueType.STRING -> { + "\"${escapeKotlinString(defaultValue)}\"" + } + + ValueType.LONG -> { + "${defaultValue}L" + } + + ValueType.FLOAT -> { + "${defaultValue}f" + } + + ValueType.ENUM -> { + "$enumTypeFqn.$defaultValue" + } + + ValueType.BOOLEAN, ValueType.INT, ValueType.DOUBLE -> { + defaultValue + } + } diff --git a/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/app/build.gradle.kts b/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/app/build.gradle.kts new file mode 100644 index 0000000..33c9d94 --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/app/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + id("com.android.application") version "9.1.0" + id("dev.androidbroadcast.featured") + id("dev.androidbroadcast.featured.application") +} + +android { + namespace = "com.example.testapp" + compileSdk = 36 + defaultConfig { minSdk = 24 } +} + +dependencies { + featuredAggregation(project(":feature-checkout")) + featuredAggregation(project(":feature-profile")) +} diff --git a/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/app/src/main/AndroidManifest.xml b/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..b2d3ea1 --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/app/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/build.gradle.kts b/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/build.gradle.kts new file mode 100644 index 0000000..b1af0dc --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/build.gradle.kts @@ -0,0 +1 @@ +// Root build file — no plugins applied at root level. diff --git a/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-checkout/build.gradle.kts b/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-checkout/build.gradle.kts new file mode 100644 index 0000000..27628c1 --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-checkout/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + id("com.android.library") version "9.1.0" + id("dev.androidbroadcast.featured") +} + +android { + namespace = "com.example.featurecheckout" + compileSdk = 36 + defaultConfig { minSdk = 24 } +} + +featured { + localFlags { + boolean("dark_mode", default = false) { category = "UI" } + enum("checkout_variant", typeFqn = "com.example.CheckoutVariant", default = "LEGACY") + } +} diff --git a/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-checkout/src/main/AndroidManifest.xml b/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-checkout/src/main/AndroidManifest.xml new file mode 100644 index 0000000..b2d3ea1 --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-checkout/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-profile/build.gradle.kts b/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-profile/build.gradle.kts new file mode 100644 index 0000000..326b95e --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-profile/build.gradle.kts @@ -0,0 +1,19 @@ +plugins { + id("com.android.library") version "9.1.0" + id("dev.androidbroadcast.featured") +} + +android { + namespace = "com.example.featureprofile" + compileSdk = 36 + defaultConfig { minSdk = 24 } +} + +featured { + localFlags { + string("avatar_placeholder", default = "default.png") + } + remoteFlags { + boolean("show_avatar", default = true) + } +} diff --git a/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-profile/src/main/AndroidManifest.xml b/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-profile/src/main/AndroidManifest.xml new file mode 100644 index 0000000..b2d3ea1 --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-profile/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/gradle.properties b/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/gradle.properties new file mode 100644 index 0000000..d621155 --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/gradle.properties @@ -0,0 +1,3 @@ +android.useAndroidX=true +org.gradle.configuration-cache=true +org.gradle.unsafe.configuration-cache-problems=warn diff --git a/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/settings.gradle.kts b/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/settings.gradle.kts new file mode 100644 index 0000000..14a18d1 --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/settings.gradle.kts @@ -0,0 +1,33 @@ +// AGP and the Featured plugin are injected via GradleRunner.withPluginClasspath(). +pluginManagement { + repositories { + google { + mavenContent { + includeGroupAndSubgroups("androidx") + includeGroupAndSubgroups("com.android") + includeGroupAndSubgroups("com.google") + } + } + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + @Suppress("UnstableApiUsage") + repositories { + google { + mavenContent { + includeGroupAndSubgroups("androidx") + includeGroupAndSubgroups("com.android") + includeGroupAndSubgroups("com.google") + } + } + mavenCentral() + } +} + +rootProject.name = "aggregator-multi-module-project" +include(":feature-checkout") +include(":feature-profile") +include(":app") diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationConfigurationTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationConfigurationTest.kt new file mode 100644 index 0000000..2624351 --- /dev/null +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationConfigurationTest.kt @@ -0,0 +1,102 @@ +package dev.androidbroadcast.featured.gradle.aggregation + +import dev.androidbroadcast.featured.gradle.manifest.SCHEMA_VERSION +import dev.androidbroadcast.featured.gradle.manifest.schemaMajorAttr +import org.gradle.api.attributes.Usage +import org.gradle.testfixtures.ProjectBuilder +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +@Suppress("UnstableApiUsage") +class FeaturedAggregationConfigurationTest { + @Test + fun `featuredAggregation configuration is registered`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured.application") + + val cfg = project.configurations.findByName(FEATURED_AGGREGATION_CONFIGURATION_NAME) + assertNotNull(cfg, "Expected '$FEATURED_AGGREGATION_CONFIGURATION_NAME' configuration to be registered") + } + + @Test + fun `featuredAggregation is declarable not consumable not resolvable`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured.application") + + val cfg = project.configurations.findByName(FEATURED_AGGREGATION_CONFIGURATION_NAME) + assertNotNull(cfg) + assertTrue(cfg.isCanBeDeclared, "Expected isCanBeDeclared = true") + assertTrue(!cfg.isCanBeConsumed, "Expected isCanBeConsumed = false") + assertTrue(!cfg.isCanBeResolved, "Expected isCanBeResolved = false") + } + + @Test + fun `featuredAggregationClasspath configuration is registered`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured.application") + + val cfg = project.configurations.findByName(FEATURED_AGGREGATION_CLASSPATH_CONFIGURATION_NAME) + assertNotNull(cfg, "Expected '$FEATURED_AGGREGATION_CLASSPATH_CONFIGURATION_NAME' configuration to be registered") + } + + @Test + fun `featuredAggregationClasspath is resolvable not consumable not declarable`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured.application") + + val cfg = project.configurations.findByName(FEATURED_AGGREGATION_CLASSPATH_CONFIGURATION_NAME) + assertNotNull(cfg) + assertTrue(cfg.isCanBeResolved, "Expected isCanBeResolved = true") + assertTrue(!cfg.isCanBeConsumed, "Expected isCanBeConsumed = false") + assertTrue(!cfg.isCanBeDeclared, "Expected isCanBeDeclared = false") + } + + @Test + fun `featuredAggregationClasspath has Usage attribute featured-manifest`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured.application") + + val cfg = project.configurations.findByName(FEATURED_AGGREGATION_CLASSPATH_CONFIGURATION_NAME) + assertNotNull(cfg) + val usageAttr = cfg.attributes.getAttribute(Usage.USAGE_ATTRIBUTE) + assertNotNull(usageAttr, "Expected Usage attribute to be set") + assertEquals( + "featured-manifest", + usageAttr.name, + "Expected Usage attribute name 'featured-manifest', got '${usageAttr.name}'", + ) + } + + @Test + fun `featuredAggregationClasspath has schema-major attribute equal to SCHEMA_VERSION`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured.application") + + val cfg = project.configurations.findByName(FEATURED_AGGREGATION_CLASSPATH_CONFIGURATION_NAME) + assertNotNull(cfg) + val schemaAttr = cfg.attributes.getAttribute(schemaMajorAttr) + assertNotNull(schemaAttr, "Expected schema-major attribute to be set") + assertEquals( + SCHEMA_VERSION, + schemaAttr, + "Expected schema-major = $SCHEMA_VERSION, got $schemaAttr", + ) + } + + @Test + fun `featuredAggregationClasspath extends featuredAggregation`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured.application") + + val declarable = project.configurations.findByName(FEATURED_AGGREGATION_CONFIGURATION_NAME) + val classpath = project.configurations.findByName(FEATURED_AGGREGATION_CLASSPATH_CONFIGURATION_NAME) + assertNotNull(declarable) + assertNotNull(classpath) + assertTrue( + classpath.extendsFrom.contains(declarable), + "Expected featuredAggregationClasspath to extend featuredAggregation", + ) + } +} diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationDescriptorIntegrityTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationDescriptorIntegrityTest.kt new file mode 100644 index 0000000..c43c0d0 --- /dev/null +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationDescriptorIntegrityTest.kt @@ -0,0 +1,217 @@ +package dev.androidbroadcast.featured.gradle.aggregation + +import dev.androidbroadcast.featured.gradle.manifest.FeaturedManifest +import dev.androidbroadcast.featured.gradle.manifest.FlagDescriptor +import dev.androidbroadcast.featured.gradle.manifest.FlagKind +import dev.androidbroadcast.featured.gradle.manifest.SCHEMA_VERSION +import dev.androidbroadcast.featured.gradle.manifest.ValueType +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertFailsWith + +class FeaturedAggregationDescriptorIntegrityTest { + private fun enumFlag( + key: String = "checkout_variant", + enumTypeFqn: String? = "com.example.CheckoutVariant", + defaultValue: String = "LEGACY", + ) = FlagDescriptor( + key = key, + propertyName = key, + kind = FlagKind.LOCAL, + valueType = ValueType.ENUM, + defaultValue = defaultValue, + enumTypeFqn = enumTypeFqn, + ) + + private fun primitiveFlag( + key: String = "some_flag", + valueType: ValueType, + defaultValue: String, + ) = FlagDescriptor( + key = key, + propertyName = key, + kind = FlagKind.LOCAL, + valueType = valueType, + defaultValue = defaultValue, + enumTypeFqn = null, + ) + + private fun singleManifest(flag: FlagDescriptor) = + listOf( + FeaturedManifest( + schemaVersion = SCHEMA_VERSION, + modulePath = ":feature-a", + flags = listOf(flag), + ), + ) + + @Test + fun `valid ENUM flag with FQN and identifier constant does not throw`() { + // Sanity: a well-formed manifest passes without exception. + validateFlagDescriptorIntegrity(singleManifest(enumFlag())) + } + + @Test + fun `ENUM flag with semicolon in FQN throws IllegalArgumentException naming key and module`() { + // Simulates a malicious FQN that would inject a Kotlin init block into the generated source. + val maliciousFqn = "kotlin.Unit>(); init { injectCode() }; private val x: ConfigParam { + validateFlagDescriptorIntegrity(singleManifest(enumFlag(enumTypeFqn = maliciousFqn))) + } + val msg = ex.message ?: "" + assertContains(msg, "checkout_variant", message = "Message must name the flag key") + assertContains(msg, ":feature-a", message = "Message must name the module path") + } + + @Test + fun `ENUM flag with angle bracket in FQN throws`() { + assertFailsWith { + validateFlagDescriptorIntegrity(singleManifest(enumFlag(enumTypeFqn = "com.example.Foo"))) + } + } + + @Test + fun `ENUM flag with parenthesis in FQN throws`() { + assertFailsWith { + validateFlagDescriptorIntegrity(singleManifest(enumFlag(enumTypeFqn = "com.example().Foo"))) + } + } + + @Test + fun `ENUM flag with brace in FQN throws`() { + assertFailsWith { + validateFlagDescriptorIntegrity(singleManifest(enumFlag(enumTypeFqn = "com.example{}.Foo"))) + } + } + + @Test + fun `ENUM flag with space in FQN throws`() { + assertFailsWith { + validateFlagDescriptorIntegrity(singleManifest(enumFlag(enumTypeFqn = "com.example .Foo"))) + } + } + + @Test + fun `ENUM flag with Unicode line separator in FQN throws`() { + // U+2028 LINE SEPARATOR — not a valid Kotlin identifier character; must be rejected. + val fqnWithLineSeparator = "com.example
Foo" + assertFailsWith { + validateFlagDescriptorIntegrity(singleManifest(enumFlag(enumTypeFqn = fqnWithLineSeparator))) + } + } + + @Test + fun `ENUM flag with injection in defaultValue throws`() { + // Simulates a malicious constant name that would inject statements into the generated source. + val maliciousDefault = "INSTANCE; injectCode()" + val ex = + assertFailsWith { + validateFlagDescriptorIntegrity(singleManifest(enumFlag(defaultValue = maliciousDefault))) + } + val msg = ex.message ?: "" + assertContains(msg, "checkout_variant", message = "Message must name the flag key") + assertContains(msg, ":feature-a", message = "Message must name the module path") + } + + @Test + fun `ENUM flag with null enumTypeFqn throws IllegalArgumentException naming key and module`() { + val ex = + assertFailsWith { + validateFlagDescriptorIntegrity(singleManifest(enumFlag(enumTypeFqn = null))) + } + val msg = ex.message ?: "" + assertContains(msg, "checkout_variant", message = "Message must name the flag key") + assertContains(msg, ":feature-a", message = "Message must name the module path") + } + + // --- Primitive defaultValue validation tests --- + + @Test + fun `BOOLEAN defaultValue 'true' does not throw`() { + validateFlagDescriptorIntegrity(singleManifest(primitiveFlag(valueType = ValueType.BOOLEAN, defaultValue = "true"))) + } + + @Test + fun `BOOLEAN defaultValue 'false' does not throw`() { + validateFlagDescriptorIntegrity(singleManifest(primitiveFlag(valueType = ValueType.BOOLEAN, defaultValue = "false"))) + } + + @Test + fun `BOOLEAN defaultValue with appended statement throws naming key and module`() { + // Simulates injection of an extra statement appended to the boolean literal. + val ex = + assertFailsWith { + validateFlagDescriptorIntegrity( + singleManifest(primitiveFlag(key = "some_flag", valueType = ValueType.BOOLEAN, defaultValue = "true; init { evil() }")), + ) + } + val msg = ex.message ?: "" + assertContains(msg, "some_flag", message = "Message must name the flag key") + assertContains(msg, ":feature-a", message = "Message must name the module path") + } + + @Test + fun `INT defaultValue with method-call suffix throws`() { + // The exact attack vector from the security review: 0.also { ... } is a valid Kotlin expression + // but must not be emitted verbatim as a ConfigParam defaultValue literal. + val ex = + assertFailsWith { + validateFlagDescriptorIntegrity( + singleManifest(primitiveFlag(key = "some_flag", valueType = ValueType.INT, defaultValue = "0.also { injectCode() }")), + ) + } + val msg = ex.message ?: "" + assertContains(msg, "some_flag", message = "Message must name the flag key") + assertContains(msg, ":feature-a", message = "Message must name the module path") + } + + @Test + fun `INT defaultValue '-42' does not throw`() { + // Negative integers are valid and must be allowed. + validateFlagDescriptorIntegrity(singleManifest(primitiveFlag(valueType = ValueType.INT, defaultValue = "-42"))) + } + + @Test + fun `LONG defaultValue max signed 64-bit value does not throw`() { + validateFlagDescriptorIntegrity( + singleManifest(primitiveFlag(valueType = ValueType.LONG, defaultValue = "9223372036854775807")), + ) + } + + @Test + fun `FLOAT defaultValue '3_14' does not throw`() { + validateFlagDescriptorIntegrity(singleManifest(primitiveFlag(valueType = ValueType.FLOAT, defaultValue = "3.14"))) + } + + @Test + fun `FLOAT defaultValue with non-numeric prefix throws`() { + val ex = + assertFailsWith { + validateFlagDescriptorIntegrity( + singleManifest(primitiveFlag(key = "some_flag", valueType = ValueType.FLOAT, defaultValue = "NaN; injectCode()")), + ) + } + val msg = ex.message ?: "" + assertContains(msg, "some_flag", message = "Message must name the flag key") + assertContains(msg, ":feature-a", message = "Message must name the module path") + } + + @Test + fun `DOUBLE defaultValue scientific notation does not throw`() { + validateFlagDescriptorIntegrity(singleManifest(primitiveFlag(valueType = ValueType.DOUBLE, defaultValue = "1.5e10"))) + } + + @Test + fun `DOUBLE defaultValue with brace injection throws`() { + val ex = + assertFailsWith { + validateFlagDescriptorIntegrity( + singleManifest(primitiveFlag(key = "some_flag", valueType = ValueType.DOUBLE, defaultValue = "1.5} init { evil() }")), + ) + } + val msg = ex.message ?: "" + assertContains(msg, "some_flag", message = "Message must name the flag key") + assertContains(msg, ":feature-a", message = "Message must name the module path") + } +} diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationDuplicateKeyTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationDuplicateKeyTest.kt new file mode 100644 index 0000000..7e7d6f1 --- /dev/null +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationDuplicateKeyTest.kt @@ -0,0 +1,137 @@ +package dev.androidbroadcast.featured.gradle.aggregation + +import dev.androidbroadcast.featured.gradle.manifest.FeaturedManifest +import dev.androidbroadcast.featured.gradle.manifest.FlagDescriptor +import dev.androidbroadcast.featured.gradle.manifest.FlagKind +import dev.androidbroadcast.featured.gradle.manifest.SCHEMA_VERSION +import dev.androidbroadcast.featured.gradle.manifest.ValueType +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertFailsWith + +class FeaturedAggregationDuplicateKeyTest { + private fun booleanFlag( + key: String, + kind: FlagKind = FlagKind.LOCAL, + ) = FlagDescriptor( + key = key, + propertyName = key, + kind = kind, + valueType = ValueType.BOOLEAN, + defaultValue = "false", + ) + + @Test + fun `no error for unique keys across modules`() { + val manifests = + listOf( + FeaturedManifest( + schemaVersion = SCHEMA_VERSION, + modulePath = ":feature-a", + flags = listOf(booleanFlag("dark_mode")), + ), + FeaturedManifest( + schemaVersion = SCHEMA_VERSION, + modulePath = ":feature-b", + flags = listOf(booleanFlag("show_banner")), + ), + ) + // Should not throw + validateUniqueKeys(manifests) + } + + @Test + fun `duplicate key across two modules throws with both module paths`() { + val manifests = + listOf( + FeaturedManifest( + schemaVersion = SCHEMA_VERSION, + modulePath = ":feature-a", + flags = listOf(booleanFlag("dark_mode")), + ), + FeaturedManifest( + schemaVersion = SCHEMA_VERSION, + modulePath = ":feature-b", + flags = listOf(booleanFlag("dark_mode")), + ), + ) + val ex = assertFailsWith { validateUniqueKeys(manifests) } + assertContains(ex.message ?: "", "dark_mode", message = "Message must contain the duplicate key") + assertContains(ex.message ?: "", ":feature-a", message = "Message must name first module path") + assertContains(ex.message ?: "", ":feature-b", message = "Message must name second module path") + } + + @Test + fun `same key in LOCAL and REMOTE of same module is a duplicate`() { + // A single module declaring the same key in both localFlags and remoteFlags. + val manifests = + listOf( + FeaturedManifest( + schemaVersion = SCHEMA_VERSION, + modulePath = ":feature-checkout", + flags = + listOf( + booleanFlag(key = "checkout_mode", kind = FlagKind.LOCAL), + booleanFlag(key = "checkout_mode", kind = FlagKind.REMOTE), + ), + ), + ) + val ex = assertFailsWith { validateUniqueKeys(manifests) } + assertContains(ex.message ?: "", "checkout_mode", message = "Message must contain the duplicate key") + // Same-module collision: both LOCAL and REMOTE markers must appear so the origin is distinguishable. + assertContains(ex.message ?: "", "LOCAL", message = "Message must name LOCAL kind") + assertContains(ex.message ?: "", "REMOTE", message = "Message must name REMOTE kind") + assertContains(ex.message ?: "", ":feature-checkout", message = "Message must name module path") + } + + @Test + fun `three modules colliding on same key all appear in error message`() { + val manifests = + listOf( + FeaturedManifest( + schemaVersion = SCHEMA_VERSION, + modulePath = ":feature-a", + flags = listOf(booleanFlag("shared_flag")), + ), + FeaturedManifest( + schemaVersion = SCHEMA_VERSION, + modulePath = ":feature-b", + flags = listOf(booleanFlag("shared_flag")), + ), + FeaturedManifest( + schemaVersion = SCHEMA_VERSION, + modulePath = ":feature-c", + flags = listOf(booleanFlag("shared_flag")), + ), + ) + val ex = assertFailsWith { validateUniqueKeys(manifests) } + val msg = ex.message ?: "" + assertContains(msg, "shared_flag", message = "Message must contain the duplicate key") + assertContains(msg, ":feature-a", message = "Message must name :feature-a") + assertContains(msg, ":feature-b", message = "Message must name :feature-b") + assertContains(msg, ":feature-c", message = "Message must name :feature-c") + } + + @Test + fun `same module LOCAL and REMOTE collision shows both LOCAL and REMOTE not just module path twice`() { + // Regression guard: before the fix the message read "':feature-checkout' and ':feature-checkout'" + // with no kind information — indistinguishable from a cross-module collision with identical names. + val manifests = + listOf( + FeaturedManifest( + schemaVersion = SCHEMA_VERSION, + modulePath = ":feature-checkout", + flags = + listOf( + booleanFlag(key = "show_avatar", kind = FlagKind.LOCAL), + booleanFlag(key = "show_avatar", kind = FlagKind.REMOTE), + ), + ), + ) + val ex = assertFailsWith { validateUniqueKeys(manifests) } + val msg = ex.message ?: "" + assertContains(msg, "show_avatar", message = "Message must contain the duplicate key") + assertContains(msg, "LOCAL", message = "Message must include LOCAL kind marker") + assertContains(msg, "REMOTE", message = "Message must include REMOTE kind marker") + } +} diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationIntegrationTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationIntegrationTest.kt new file mode 100644 index 0000000..84de5c3 --- /dev/null +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationIntegrationTest.kt @@ -0,0 +1,198 @@ +package dev.androidbroadcast.featured.gradle.aggregation + +import dev.androidbroadcast.featured.gradle.manifest.androidSdkDirOrNull +import dev.androidbroadcast.featured.gradle.manifest.copyManifestFixture +import org.gradle.testkit.runner.GradleRunner +import org.gradle.testkit.runner.TaskOutcome +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Integration tests for the multi-module registry aggregation using the + * `aggregator-multi-module-project` fixture (two Android library modules + aggregating app). + * + * Skipped when `ANDROID_HOME` / `ANDROID_SDK_ROOT` is not set. + */ +class FeaturedAggregationIntegrationTest { + @get:Rule + val tempFolder = TemporaryFolder() + + private lateinit var projectDir: File + + @Before + fun setUp() { + val sdkDir = androidSdkDirOrNull() + assumeTrue( + "ANDROID_HOME or ANDROID_SDK_ROOT must be set to run integration tests", + sdkDir != null, + ) + + projectDir = tempFolder.newFolder("aggregator-multi-module-project") + copyManifestFixture(fixtureName = "aggregator-multi-module-project", dest = projectDir) + + // Write local.properties with the real SDK path — use invariantSeparatorsPath so that + // a raw Windows SDK path would not corrupt local.properties. + projectDir.resolve("local.properties").writeText("sdk.dir=${sdkDir!!.invariantSeparatorsPath}\n") + } + + @Test + fun `generateFeaturedRegistry succeeds`() { + val result = + gradleRunner() + .withArguments(":app:$GENERATE_FEATURED_REGISTRY_TASK_NAME", "--stacktrace") + .build() + + val outcome = result.task(":app:$GENERATE_FEATURED_REGISTRY_TASK_NAME")?.outcome + assertEquals( + TaskOutcome.SUCCESS, + outcome, + "Expected :app:$GENERATE_FEATURED_REGISTRY_TASK_NAME to succeed, got $outcome\n${result.output}", + ) + } + + @Test + fun `generated file exists at expected path`() { + gradleRunner() + .withArguments(":app:$GENERATE_FEATURED_REGISTRY_TASK_NAME") + .build() + + val generatedFile = + projectDir.resolve( + "app/build/generated/featured/commonMain/${FEATURED_REGISTRY_OBJECT}.kt", + ) + assertTrue(generatedFile.exists(), "Expected generated file at ${generatedFile.path}") + } + + @Test + fun `generated source contains expected ConfigParam entries`() { + gradleRunner() + .withArguments(":app:$GENERATE_FEATURED_REGISTRY_TASK_NAME") + .build() + + val source = + projectDir + .resolve("app/build/generated/featured/commonMain/${FEATURED_REGISTRY_OBJECT}.kt") + .readText() + + assertTrue(source.contains("object $FEATURED_REGISTRY_OBJECT"), "Missing object declaration") + assertTrue(source.contains("listOf("), "Missing listOf() in generated source") + assertTrue( + source.contains("ConfigParam(key = \"dark_mode\""), + "Missing dark_mode (Boolean) entry", + ) + assertTrue( + source.contains("ConfigParam(key = \"checkout_variant\""), + "Missing checkout_variant (ENUM) entry", + ) + assertTrue( + source.contains("ConfigParam(key = \"show_avatar\""), + "Missing show_avatar (Boolean) entry", + ) + assertTrue( + source.contains("ConfigParam(key = \"avatar_placeholder\""), + "Missing avatar_placeholder (String) entry", + ) + } + + @Test + fun `second run without changes reports UP_TO_DATE`() { + gradleRunner() + .withArguments(":app:$GENERATE_FEATURED_REGISTRY_TASK_NAME") + .build() + + val result = + gradleRunner() + .withArguments(":app:$GENERATE_FEATURED_REGISTRY_TASK_NAME") + .build() + + val outcome = result.task(":app:$GENERATE_FEATURED_REGISTRY_TASK_NAME")?.outcome + assertTrue( + outcome == TaskOutcome.UP_TO_DATE || outcome == TaskOutcome.FROM_CACHE, + "Expected UP_TO_DATE or FROM_CACHE on second run, got $outcome", + ) + } + + @Test + fun `mutating a feature module invalidates the registry task`() { + gradleRunner() + .withArguments(":app:$GENERATE_FEATURED_REGISTRY_TASK_NAME") + .build() + + // Add a new flag to :feature-checkout to invalidate the manifest artifact. + val buildFile = projectDir.resolve("feature-checkout/build.gradle.kts") + buildFile.writeText( + buildFile.readText().replace( + "enum(\"checkout_variant\", typeFqn = \"com.example.CheckoutVariant\", default = \"LEGACY\")", + "enum(\"checkout_variant\", typeFqn = \"com.example.CheckoutVariant\", default = \"LEGACY\")\n" + + " int(\"max_retries\", default = 3)", + ), + ) + + val result = + gradleRunner() + .withArguments(":app:$GENERATE_FEATURED_REGISTRY_TASK_NAME") + .build() + + val outcome = result.task(":app:$GENERATE_FEATURED_REGISTRY_TASK_NAME")?.outcome + assertEquals( + TaskOutcome.SUCCESS, + outcome, + "Expected SUCCESS after input change, got $outcome", + ) + } + + @Test + fun `configuration cache stores on first run`() { + val result = + gradleRunner() + .withArguments( + ":app:$GENERATE_FEATURED_REGISTRY_TASK_NAME", + "--configuration-cache", + "--configuration-cache-problems=warn", + ).build() + + assertTrue( + result.output.contains("Configuration cache entry stored"), + "Expected 'Configuration cache entry stored' in output, got:\n${result.output}", + ) + } + + @Test + fun `configuration cache is reused on second run`() { + gradleRunner() + .withArguments( + ":app:$GENERATE_FEATURED_REGISTRY_TASK_NAME", + "--configuration-cache", + "--configuration-cache-problems=warn", + ).build() + + val secondRun = + gradleRunner() + .withArguments( + ":app:$GENERATE_FEATURED_REGISTRY_TASK_NAME", + "--configuration-cache", + "--configuration-cache-problems=warn", + ).build() + + assertTrue( + secondRun.output.contains("Configuration cache entry reused") || + secondRun.output.contains("Reusing configuration cache"), + "Expected CC reuse marker in second-run output, got:\n${secondRun.output}", + ) + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private fun gradleRunner(): GradleRunner = + GradleRunner + .create() + .withProjectDir(projectDir) + .withPluginClasspath() + .forwardOutput() +} diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationParseErrorTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationParseErrorTest.kt new file mode 100644 index 0000000..bb4f85b --- /dev/null +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationParseErrorTest.kt @@ -0,0 +1,46 @@ +package dev.androidbroadcast.featured.gradle.aggregation + +import org.gradle.testfixtures.ProjectBuilder +import java.io.File +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertFailsWith + +/** + * Verifies that a corrupt or malformed manifest file produces an [IllegalStateException] + * whose message includes the file path so the developer can locate the bad file immediately. + * + * Paired with Fix 3 in GenerateFeaturedRegistryTask. + */ +@Suppress("UnstableApiUsage") +class FeaturedAggregationParseErrorTest { + @Test + fun `malformed manifest json produces IllegalStateException containing file path`() { + val tempDir = Files.createTempDirectory("featured-parse-error-test").toFile() + try { + val badManifest = + File(tempDir, "featured-manifest.json").also { + it.writeText("""{ "broken": json""") + } + val outputFile = File(tempDir, "GeneratedFeaturedRegistry.kt") + + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured.application") + + val task = project.tasks.findByName(GENERATE_FEATURED_REGISTRY_TASK_NAME) as GenerateFeaturedRegistryTask + task.manifestFiles.from(badManifest) + task.outputPackage.set(FEATURED_REGISTRY_PACKAGE) + task.outputFile.set(outputFile) + + val ex = assertFailsWith { task.generate() } + assertContains( + ex.message ?: "", + badManifest.path, + message = "Exception message must include the path of the malformed manifest file", + ) + } finally { + tempDir.deleteRecursively() + } + } +} diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GenerateFeaturedRegistryTaskRegistrationTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GenerateFeaturedRegistryTaskRegistrationTest.kt new file mode 100644 index 0000000..0cde118 --- /dev/null +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GenerateFeaturedRegistryTaskRegistrationTest.kt @@ -0,0 +1,90 @@ +package dev.androidbroadcast.featured.gradle.aggregation + +import org.gradle.testfixtures.ProjectBuilder +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +@Suppress("UnstableApiUsage") +class GenerateFeaturedRegistryTaskRegistrationTest { + @Test + fun `plugin registers generateFeaturedRegistry task`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured.application") + + assertTrue( + project.tasks.names.contains(GENERATE_FEATURED_REGISTRY_TASK_NAME), + "Expected '$GENERATE_FEATURED_REGISTRY_TASK_NAME' task to be registered lazily by the plugin", + ) + } + + @Test + fun `generateFeaturedRegistry task is of correct type`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured.application") + + val task = project.tasks.findByName(GENERATE_FEATURED_REGISTRY_TASK_NAME) + assertNotNull(task, "Expected '$GENERATE_FEATURED_REGISTRY_TASK_NAME' task to be registered") + assertTrue( + task is GenerateFeaturedRegistryTask, + "Expected task type GenerateFeaturedRegistryTask but was ${task::class.simpleName}", + ) + } + + @Test + fun `generateFeaturedRegistry task is in featured group`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured.application") + + val task = project.tasks.findByName(GENERATE_FEATURED_REGISTRY_TASK_NAME) as? GenerateFeaturedRegistryTask + assertNotNull(task) + assertEquals("featured", task.group, "Expected task group 'featured' but was '${task.group}'") + } + + @Test + fun `generateFeaturedRegistry task outputPackage defaults to FEATURED_REGISTRY_PACKAGE`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured.application") + + val task = project.tasks.findByName(GENERATE_FEATURED_REGISTRY_TASK_NAME) as? GenerateFeaturedRegistryTask + assertNotNull(task) + assertEquals( + FEATURED_REGISTRY_PACKAGE, + task.outputPackage.get(), + "Expected outputPackage == FEATURED_REGISTRY_PACKAGE", + ) + } + + @Test + fun `generateFeaturedRegistry task outputFile path follows convention`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured.application") + + val task = project.tasks.findByName(GENERATE_FEATURED_REGISTRY_TASK_NAME) as? GenerateFeaturedRegistryTask + assertNotNull(task) + val outputPath = + task.outputFile + .get() + .asFile.path + assertTrue( + outputPath.endsWith("build/generated/featured/commonMain/${FEATURED_REGISTRY_OBJECT}.kt"), + "Expected outputFile to end with 'build/generated/featured/commonMain/${FEATURED_REGISTRY_OBJECT}.kt', got: $outputPath", + ) + } + + @Test + fun `accessing featuredAggregationClasspath configuration does not eagerly realize generateFeaturedRegistry task`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured.application") + + // Accessing the configuration by name must not trigger task realization. + project.configurations.getByName(FEATURED_AGGREGATION_CLASSPATH_CONFIGURATION_NAME) + + // The task must still be present in the task graph (registered lazily). + assertTrue( + project.tasks.names.contains(GENERATE_FEATURED_REGISTRY_TASK_NAME), + "Expected '$GENERATE_FEATURED_REGISTRY_TASK_NAME' to be in task names (lazy)", + ) + } +} diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GeneratedFeaturedRegistryGeneratorTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GeneratedFeaturedRegistryGeneratorTest.kt new file mode 100644 index 0000000..5ecaf60 --- /dev/null +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GeneratedFeaturedRegistryGeneratorTest.kt @@ -0,0 +1,382 @@ +package dev.androidbroadcast.featured.gradle.aggregation + +import dev.androidbroadcast.featured.gradle.manifest.FeaturedManifest +import dev.androidbroadcast.featured.gradle.manifest.FlagDescriptor +import dev.androidbroadcast.featured.gradle.manifest.FlagKind +import dev.androidbroadcast.featured.gradle.manifest.SCHEMA_VERSION +import dev.androidbroadcast.featured.gradle.manifest.ValueType +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class GeneratedFeaturedRegistryGeneratorTest { + private fun manifest( + modulePath: String, + vararg flags: FlagDescriptor, + ): FeaturedManifest = + FeaturedManifest( + schemaVersion = SCHEMA_VERSION, + modulePath = modulePath, + flags = flags.toList(), + ) + + private fun flag( + key: String, + valueType: ValueType, + defaultValue: String, + kind: FlagKind = FlagKind.LOCAL, + enumTypeFqn: String? = null, + description: String? = null, + category: String? = null, + ) = FlagDescriptor( + key = key, + propertyName = key, + kind = kind, + valueType = valueType, + defaultValue = defaultValue, + enumTypeFqn = enumTypeFqn, + description = description, + category = category, + ) + + @Test + fun `empty manifests list produces emptyList body`() { + val source = + GeneratedFeaturedRegistryGenerator.generate( + manifests = emptyList(), + packageName = FEATURED_REGISTRY_PACKAGE, + ) + assertContains(source, "emptyList()") + assertFalse(source.contains("listOf("), "Expected no listOf when empty") + } + + @Test + fun `single BOOLEAN local flag emits correct ConfigParam`() { + val source = + GeneratedFeaturedRegistryGenerator.generate( + manifests = listOf(manifest(":app", flag(key = "dark_mode", valueType = ValueType.BOOLEAN, defaultValue = "false"))), + packageName = FEATURED_REGISTRY_PACKAGE, + ) + assertContains(source, "ConfigParam(key = \"dark_mode\", defaultValue = false)") + } + + @Test + fun `LONG suffix is L`() { + val source = + GeneratedFeaturedRegistryGenerator.generate( + manifests = listOf(manifest(":app", flag(key = "timeout", valueType = ValueType.LONG, defaultValue = "123"))), + packageName = FEATURED_REGISTRY_PACKAGE, + ) + assertContains(source, "defaultValue = 123L") + } + + @Test + fun `FLOAT suffix is f`() { + val source = + GeneratedFeaturedRegistryGenerator.generate( + manifests = listOf(manifest(":app", flag(key = "ratio", valueType = ValueType.FLOAT, defaultValue = "1.5"))), + packageName = FEATURED_REGISTRY_PACKAGE, + ) + assertContains(source, "defaultValue = 1.5f") + } + + @Test + fun `DOUBLE emits raw value`() { + val source = + GeneratedFeaturedRegistryGenerator.generate( + manifests = listOf(manifest(":app", flag(key = "pi", valueType = ValueType.DOUBLE, defaultValue = "3.14"))), + packageName = FEATURED_REGISTRY_PACKAGE, + ) + assertContains(source, "defaultValue = 3.14") + assertFalse(source.contains("3.14f"), "DOUBLE must not have f suffix") + assertFalse(source.contains("3.14L"), "DOUBLE must not have L suffix") + } + + @Test + fun `INT emits raw value`() { + val source = + GeneratedFeaturedRegistryGenerator.generate( + manifests = listOf(manifest(":app", flag(key = "retries", valueType = ValueType.INT, defaultValue = "3"))), + packageName = FEATURED_REGISTRY_PACKAGE, + ) + assertContains(source, "defaultValue = 3") + assertFalse(source.contains("3L"), "INT must not have L suffix") + } + + @Test + fun `STRING re-wraps bare value in quotes`() { + // Producer stores bare value: "hello world" (no surrounding quotes) + val source = + GeneratedFeaturedRegistryGenerator.generate( + manifests = listOf(manifest(":app", flag(key = "label", valueType = ValueType.STRING, defaultValue = "hello world"))), + packageName = FEATURED_REGISTRY_PACKAGE, + ) + assertContains(source, "defaultValue = \"hello world\"") + } + + @Test + fun `STRING escapes embedded double quotes`() { + // Producer stores bare: say "hi" — generator must emit: "say \"hi\"" + val source = + GeneratedFeaturedRegistryGenerator.generate( + manifests = listOf(manifest(":app", flag(key = "greeting", valueType = ValueType.STRING, defaultValue = """say "hi""""))), + packageName = FEATURED_REGISTRY_PACKAGE, + ) + assertContains(source, """defaultValue = "say \"hi\"""") + } + + @Test + fun `ENUM emits enumTypeFqn dot constant as default and type arg`() { + val source = + GeneratedFeaturedRegistryGenerator.generate( + manifests = + listOf( + manifest( + ":feature", + flag( + key = "checkout_variant", + valueType = ValueType.ENUM, + defaultValue = "LEGACY", + enumTypeFqn = "com.example.CheckoutVariant", + ), + ), + ), + packageName = FEATURED_REGISTRY_PACKAGE, + ) + assertContains(source, "ConfigParam") + assertContains(source, "defaultValue = com.example.CheckoutVariant.LEGACY") + } + + @Test + fun `multi-module input lists all flags`() { + val moduleA = + manifest( + ":feature-a", + flag(key = "flag_a1", valueType = ValueType.BOOLEAN, defaultValue = "true"), + flag(key = "flag_a2", valueType = ValueType.INT, defaultValue = "1"), + ) + val moduleB = + manifest( + ":feature-b", + flag(key = "flag_b1", valueType = ValueType.STRING, defaultValue = "hello"), + flag(key = "flag_b2", valueType = ValueType.LONG, defaultValue = "99"), + ) + val source = + GeneratedFeaturedRegistryGenerator.generate( + manifests = listOf(moduleA, moduleB), + packageName = FEATURED_REGISTRY_PACKAGE, + ) + assertContains(source, "flag_a1") + assertContains(source, "flag_a2") + assertContains(source, "flag_b1") + assertContains(source, "flag_b2") + } + + @Test + fun `stable order manifests in B-A input produce flags sorted by modulePath then key`() { + // Manifests passed in [B, A] order — output must be A's flags first, then B's. + val moduleA = + manifest( + ":feature-a", + flag(key = "z_flag", valueType = ValueType.BOOLEAN, defaultValue = "false"), + flag(key = "a_flag", valueType = ValueType.BOOLEAN, defaultValue = "true"), + ) + val moduleB = + manifest( + ":feature-b", + flag(key = "m_flag", valueType = ValueType.INT, defaultValue = "5"), + ) + // Pass B before A intentionally + val source = + GeneratedFeaturedRegistryGenerator.generate( + manifests = listOf(moduleB, moduleA), + packageName = FEATURED_REGISTRY_PACKAGE, + ) + val aFlagPos = source.indexOf("a_flag") + val zFlagPos = source.indexOf("z_flag") + val mFlagPos = source.indexOf("m_flag") + + // :feature-a < :feature-b alphabetically; within :feature-a, a_flag < z_flag + assertTrue(aFlagPos < zFlagPos, "a_flag must appear before z_flag (within :feature-a)") + assertTrue(zFlagPos < mFlagPos, "z_flag (:feature-a) must appear before m_flag (:feature-b)") + } + + @Test + fun `optional description is emitted when non-null`() { + val source = + GeneratedFeaturedRegistryGenerator.generate( + manifests = + listOf( + manifest( + ":app", + flag( + key = "my_flag", + valueType = ValueType.BOOLEAN, + defaultValue = "true", + description = "Controls the widget", + ), + ), + ), + packageName = FEATURED_REGISTRY_PACKAGE, + ) + assertContains(source, "description = \"Controls the widget\"") + } + + @Test + fun `null description is omitted from ConfigParam args`() { + val source = + GeneratedFeaturedRegistryGenerator.generate( + manifests = + listOf( + manifest(":app", flag(key = "my_flag", valueType = ValueType.BOOLEAN, defaultValue = "false")), + ), + packageName = FEATURED_REGISTRY_PACKAGE, + ) + assertFalse(source.contains("description ="), "description must be absent when null") + } + + @Test + fun `since parameter is never emitted`() { + // Manifest schema v1 has no since field; ConfigParam accepts it but we never emit it + val source = + GeneratedFeaturedRegistryGenerator.generate( + manifests = + listOf( + manifest(":app", flag(key = "feature", valueType = ValueType.BOOLEAN, defaultValue = "true")), + ), + packageName = FEATURED_REGISTRY_PACKAGE, + ) + assertFalse(source.contains("since ="), "since must never be emitted") + } + + // NIT 4 — escape paths for STRING default: backslash and dollar sign + + @Test + fun `STRING default with backslash is escaped`() { + // Producer stores bare: path\to\file — generator must emit: "path\\to\\file" + val source = + GeneratedFeaturedRegistryGenerator.generate( + manifests = + listOf(manifest(":app", flag(key = "path_flag", valueType = ValueType.STRING, defaultValue = """path\to\file"""))), + packageName = FEATURED_REGISTRY_PACKAGE, + ) + assertContains(source, """defaultValue = "path\\to\\file"""") + } + + @Test + fun `STRING default with dollar sign is escaped to prevent template interpolation`() { + // Producer stores bare: price $9.99 — generator must emit: "price ${'$'}9.99" + val source = + GeneratedFeaturedRegistryGenerator.generate( + manifests = + listOf(manifest(":app", flag(key = "price_flag", valueType = ValueType.STRING, defaultValue = "price \$9.99"))), + packageName = FEATURED_REGISTRY_PACKAGE, + ) + // The generated source must contain the Kotlin-safe form that prevents interpolation. + assertContains(source, "price \${'\$'}9.99") + } + + @Test + fun `key containing double quote is escaped in generated source`() { + val source = + GeneratedFeaturedRegistryGenerator.generate( + manifests = + listOf(manifest(":app", flag(key = """dark"mode""", valueType = ValueType.BOOLEAN, defaultValue = "false"))), + packageName = FEATURED_REGISTRY_PACKAGE, + ) + // Generated key must have the quote escaped: key = "dark\"mode" + assertContains(source, """key = "dark\"mode"""") + } + + @Test + fun `description containing dollar sign is escaped`() { + val source = + GeneratedFeaturedRegistryGenerator.generate( + manifests = + listOf( + manifest( + ":app", + flag( + key = "promo", + valueType = ValueType.BOOLEAN, + defaultValue = "true", + description = "Price: \$9.99", + ), + ), + ), + packageName = FEATURED_REGISTRY_PACKAGE, + ) + assertContains(source, "Price: \${'\$'}9.99") + } + + // Fix 1 — newline / tab escape in STRING default and description + + @Test + fun `STRING default with newline is escaped to backslash-n`() { + // Producer stores a value with a real newline character; generated source must not contain a raw newline. + val source = + GeneratedFeaturedRegistryGenerator.generate( + manifests = listOf(manifest(":app", flag(key = "multiline", valueType = ValueType.STRING, defaultValue = "line1\nline2"))), + packageName = FEATURED_REGISTRY_PACKAGE, + ) + assertContains(source, """defaultValue = "line1\nline2"""") + assertFalse(source.contains("line1\nline2"), "Raw newline must not appear in the generated source") + } + + @Test + fun `description with newline is escaped to backslash-n`() { + val source = + GeneratedFeaturedRegistryGenerator.generate( + manifests = + listOf( + manifest( + ":app", + flag( + key = "flag", + valueType = ValueType.BOOLEAN, + defaultValue = "false", + description = "first line\nsecond line", + ), + ), + ), + packageName = FEATURED_REGISTRY_PACKAGE, + ) + assertContains(source, """description = "first line\nsecond line"""") + assertFalse(source.contains("first line\nsecond line"), "Raw newline must not appear in description") + } + + @Test + fun `STRING default with tab is escaped to backslash-t`() { + val source = + GeneratedFeaturedRegistryGenerator.generate( + manifests = listOf(manifest(":app", flag(key = "tabbed", valueType = ValueType.STRING, defaultValue = "col1\tcol2"))), + packageName = FEATURED_REGISTRY_PACKAGE, + ) + assertContains(source, """defaultValue = "col1\tcol2"""") + assertFalse(source.contains("col1\tcol2"), "Raw tab must not appear in the generated source") + } + + // NIT 5 — category emit/omit + + @Test + fun `optional category is emitted when non-null`() { + val source = + GeneratedFeaturedRegistryGenerator.generate( + manifests = + listOf( + manifest( + ":app", + flag( + key = "dark_mode", + valueType = ValueType.BOOLEAN, + defaultValue = "false", + category = "UI", + ), + ), + ), + packageName = FEATURED_REGISTRY_PACKAGE, + ) + assertContains(source, "category = \"UI\"") + } +} From e3fe2bbefb536a09310658e71b541777ba7b8624 Mon Sep 17 00:00:00 2001 From: Kirill Rozov Date: Tue, 19 May 2026 20:11:35 +0300 Subject: [PATCH 09/15] Remove FlagRegistry; make FeatureFlagsDebugScreen UI-agnostic (#199) PR C of 4-PR redesign of Featured flag registry. - FeatureFlagsDebugScreen now takes explicit registry: List> parameter; pass GeneratedFeaturedRegistry.all (from dev.androidbroadcast.featured.application plugin) or build inline. Each ConfigParam.key must be unique; LaunchedEffect keys via equals(). - Removed featured-registry module (FlagRegistry singleton + expect/actual FlagRegistryDelegate). - Removed generateFlagRegistrar task, FlagRegistrarGenerator, GenerateFlagRegistrarTask, dead LocalFlagEntry.kotlinReference. - Sample now exposes SampleFeatureFlags.all; registerSampleFlags() removed. - Docs synced (CHANGELOG, README, wiki, CLAUDE.md). CI cleanup: dropped stale :featured-registry:koverVerify from ci.yml. Breaking change; Featured does not keep API compatibility. --- .github/workflows/ci.yml | 1 - CHANGELOG.md | 10 ++ featured-bom/build.gradle.kts | 1 - featured-debug-ui/README.md | 18 ++- featured-debug-ui/build.gradle.kts | 1 - .../debugui/FeatureFlagsDebugScreen.kt | 25 ++- featured-gradle-plugin/CLAUDE.md | 1 - .../featured/gradle/FeaturedPlugin.kt | 21 +-- .../featured/gradle/FlagRegistrarGenerator.kt | 65 -------- .../gradle/GenerateFlagRegistrarTask.kt | 70 --------- .../featured/gradle/LocalFlagEntry.kt | 15 -- .../featured/gradle/ResolveFlagsTask.kt | 4 +- .../gradle/FlagRegistrarGeneratorTest.kt | 143 ------------------ ...nerateFlagRegistrarTaskRegistrationTest.kt | 110 -------------- .../LocalFlagEntryKotlinReferenceTest.kt | 80 ---------- .../featured/gradle/LocalFlagEntryTest.kt | 32 ++++ featured-registry/CLAUDE.md | 9 -- featured-registry/README.md | 19 --- featured-registry/build.gradle.kts | 143 ------------------ .../featured/registry/FlagRegistry.kt | 34 ----- .../featured/registry/FlagRegistryDelegate.kt | 11 -- .../featured/registry/FlagRegistryTest.kt | 64 -------- .../featured/registry/FlagRegistryDelegate.kt | 26 ---- .../featured/registry/FlagRegistryDelegate.kt | 22 --- .../featured/sample/MainActivity.kt | 7 +- sample/shared/build.gradle.kts | 1 - .../androidbroadcast/featured/SampleApp.kt | 16 -- .../featured/SampleFeatureFlags.kt | 10 ++ settings.gradle.kts | 1 - 29 files changed, 88 insertions(+), 872 deletions(-) delete mode 100644 featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FlagRegistrarGenerator.kt delete mode 100644 featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateFlagRegistrarTask.kt delete mode 100644 featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FlagRegistrarGeneratorTest.kt delete mode 100644 featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/GenerateFlagRegistrarTaskRegistrationTest.kt delete mode 100644 featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/LocalFlagEntryKotlinReferenceTest.kt create mode 100644 featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/LocalFlagEntryTest.kt delete mode 100644 featured-registry/CLAUDE.md delete mode 100644 featured-registry/README.md delete mode 100644 featured-registry/build.gradle.kts delete mode 100644 featured-registry/src/commonMain/kotlin/dev/androidbroadcast/featured/registry/FlagRegistry.kt delete mode 100644 featured-registry/src/commonMain/kotlin/dev/androidbroadcast/featured/registry/FlagRegistryDelegate.kt delete mode 100644 featured-registry/src/commonTest/kotlin/dev/androidbroadcast/featured/registry/FlagRegistryTest.kt delete mode 100644 featured-registry/src/iosMain/kotlin/dev/androidbroadcast/featured/registry/FlagRegistryDelegate.kt delete mode 100644 featured-registry/src/jvmCommonMain/kotlin/dev/androidbroadcast/featured/registry/FlagRegistryDelegate.kt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 328bb40..97d2b8b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,6 @@ jobs: :providers:sharedpreferences:koverVerify :providers:firebase:koverVerify :featured-compose:koverVerify - :featured-registry:koverVerify :featured-testing:koverVerify - uses: actions/upload-artifact@v7 if: always() diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fe9f6c..7fc14e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Removed + +- `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>` 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. + +### Changed + +- `FeatureFlagsDebugScreen` signature is now `(configValues: ConfigValues, registry: List>, 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. + ### 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. diff --git a/featured-bom/build.gradle.kts b/featured-bom/build.gradle.kts index 9bd2fff..d00b7cf 100644 --- a/featured-bom/build.gradle.kts +++ b/featured-bom/build.gradle.kts @@ -18,7 +18,6 @@ dependencies { api(project(":providers:configcat")) api(project(":featured-compose")) - api(project(":featured-registry")) api(project(":featured-debug-ui")) api(project(":featured-testing")) api(project(":featured-gradle-plugin")) diff --git a/featured-debug-ui/README.md b/featured-debug-ui/README.md index bc94558..6c59145 100644 --- a/featured-debug-ui/README.md +++ b/featured-debug-ui/README.md @@ -6,18 +6,28 @@ Ready-made Compose screen for runtime flag inspection and override. Intended for ```kotlin debugImplementation("dev.androidbroadcast.featured:featured-debug-ui") -debugImplementation("dev.androidbroadcast.featured:featured-registry") // required ``` ## Usage -Embed `FeatureFlagsDebugScreen` behind a dev-menu, shake gesture, or debug settings screen: +Embed `FeatureFlagsDebugScreen` behind a dev-menu, shake gesture, or debug settings screen. Pass an explicit `List>` as the registry. + +**Recommended — use the aggregator plugin** (multi-module projects): apply `dev.androidbroadcast.featured.application` in the app module and declare feature modules via `featuredAggregation(project(...))`. The plugin generates `GeneratedFeaturedRegistry.all` at build time. + +```kotlin +FeatureFlagsDebugScreen( + configValues = configValues, + registry = GeneratedFeaturedRegistry.all, +) +``` + +**Alternative — inline list** (small / single-module projects): ```kotlin FeatureFlagsDebugScreen( - registry = FlagRegistry.instance, configValues = configValues, + registry = listOf(MyFlags.flagA, MyFlags.flagB), ) ``` -The screen displays all registered flags grouped by category, shows the current value and its source (DEFAULT / LOCAL / REMOTE), and allows overriding any flag value in-process. Overrides are applied via `ConfigValues.override()` and survive until the app process is restarted (or `clearOverrides()` is called). +The screen displays all registry flags grouped by category, shows the current value and its source (DEFAULT / LOCAL / REMOTE), and allows overriding any flag value in-process. Overrides are applied via `ConfigValues.override()` and survive until the app process is restarted (or `clearOverrides()` is called). diff --git a/featured-debug-ui/build.gradle.kts b/featured-debug-ui/build.gradle.kts index 3ca03f4..7b2883f 100644 --- a/featured-debug-ui/build.gradle.kts +++ b/featured-debug-ui/build.gradle.kts @@ -44,7 +44,6 @@ kotlin { sourceSets { commonMain.dependencies { implementation(projects.core) - implementation(projects.featuredRegistry) implementation(compose.runtime) implementation(compose.foundation) implementation(compose.material3) diff --git a/featured-debug-ui/src/commonMain/kotlin/dev/androidbroadcast/featured/debugui/FeatureFlagsDebugScreen.kt b/featured-debug-ui/src/commonMain/kotlin/dev/androidbroadcast/featured/debugui/FeatureFlagsDebugScreen.kt index 6d5361d..0e4828b 100644 --- a/featured-debug-ui/src/commonMain/kotlin/dev/androidbroadcast/featured/debugui/FeatureFlagsDebugScreen.kt +++ b/featured-debug-ui/src/commonMain/kotlin/dev/androidbroadcast/featured/debugui/FeatureFlagsDebugScreen.kt @@ -38,12 +38,11 @@ import androidx.compose.ui.unit.dp import dev.androidbroadcast.featured.ConfigParam import dev.androidbroadcast.featured.ConfigValue import dev.androidbroadcast.featured.ConfigValues -import dev.androidbroadcast.featured.registry.FlagRegistry import kotlinx.coroutines.flow.merge import kotlinx.coroutines.launch /** - * A ready-to-use debug screen that lists all feature flags registered in [FlagRegistry] + * A ready-to-use debug screen that lists all feature flags in the provided [registry] * and allows toggling boolean flags or viewing current values for other types. * * Flags are grouped by [ConfigParam.category]. Each flag shows its current value, source @@ -55,7 +54,17 @@ import kotlinx.coroutines.launch * * Intended for debug/internal builds only. * + * Pass `GeneratedFeaturedRegistry.all` (from the `dev.androidbroadcast.featured.application` + * plugin) or build the list explicitly. + * * @param configValues The [ConfigValues] instance used to read and override flag values. + * @param registry The list of [ConfigParam] instances to display. Must be a stable + * reference (a top-level `val`, an `object` property, or a `remember`-ed list). + * The screen keys its internal `LaunchedEffect` on this list via `equals` (structural). + * A freshly-allocated list on every recomposition may restart the effect; prefer a + * stable top-level `val` or `object` property for predictable behavior. + * Each [ConfigParam.key] must be unique within the list; duplicates cause a + * runtime crash in `LazyColumn` key collision. * @param modifier Optional [Modifier] for the root composable. */ @OptIn(ExperimentalMaterial3Api::class) @@ -63,6 +72,7 @@ import kotlinx.coroutines.launch @Suppress("ktlint:standard:function-naming") public fun FeatureFlagsDebugScreen( configValues: ConfigValues, + registry: List>, modifier: Modifier = Modifier, ) { val scope = rememberCoroutineScope() @@ -70,16 +80,15 @@ public fun FeatureFlagsDebugScreen( mutableStateOf>>>(emptyMap()) } - LaunchedEffect(configValues) { - val params = FlagRegistry.all() - groupedItems = groupFlagsByCategory(buildDebugItems(configValues, params)) + LaunchedEffect(configValues, registry) { + groupedItems = groupFlagsByCategory(buildDebugItems(configValues, registry)) // Reactive: observe all params and refresh on any change. // On each emission all params are re-read — acceptable for a debug-only screen. - val flows = params.map { param -> configValues.observe(param) } + val flows = registry.map { param -> configValues.observe(param) } if (flows.isNotEmpty()) { flows.merge().collect { - groupedItems = groupFlagsByCategory(buildDebugItems(configValues, params)) + groupedItems = groupFlagsByCategory(buildDebugItems(configValues, registry)) } } } @@ -97,7 +106,7 @@ public fun FeatureFlagsDebugScreen( horizontalAlignment = Alignment.CenterHorizontally, ) { Text( - text = "No feature flags registered.", + text = "No feature flags to display.", style = MaterialTheme.typography.bodyMedium, ) } diff --git a/featured-gradle-plugin/CLAUDE.md b/featured-gradle-plugin/CLAUDE.md index 5f50182..df44b59 100644 --- a/featured-gradle-plugin/CLAUDE.md +++ b/featured-gradle-plugin/CLAUDE.md @@ -27,7 +27,6 @@ featured { |------|--------| | `resolveFeatureFlags` | `build/featured/flags.txt` | | `generateConfigParam` | `build/generated/featured/commonMain/Generated{Local,Remote}Flags.kt` + `GeneratedFlagExtensions.kt` | -| `generateFlagRegistrar` | `build/generated/featured/GeneratedFlagRegistrar.kt` | | `generateFeaturedProguardRules` | `build/featured/proguard-featured.pro` | | `generateIosConstVal` | iOS constant value files | | `generateXcconfig` | `build/featured/FeatureFlags.generated.xcconfig` | diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPlugin.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPlugin.kt index 55bd264..b331e8b 100644 --- a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPlugin.kt +++ b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPlugin.kt @@ -13,7 +13,6 @@ import org.gradle.api.tasks.TaskProvider internal const val RESOLVE_FLAGS_TASK_NAME = "resolveFeatureFlags" internal const val SCAN_ALL_TASK_NAME = "scanAllLocalFlags" -internal const val GENERATE_FLAG_REGISTRAR_TASK_NAME = "generateFlagRegistrar" internal const val GENERATE_PROGUARD_TASK_NAME = "generateFeaturedProguardRules" internal const val GENERATE_IOS_CONST_VAL_TASK_NAME = "generateIosConstVal" internal const val GENERATE_XCCONFIG_TASK_NAME = "generateXcconfig" @@ -24,8 +23,7 @@ internal const val GENERATE_CONFIG_PARAM_TASK_NAME = "generateConfigParam" * 1. Exposes the `featured { }` DSL extension for declaring local and remote feature flags. * 2. Generates typed `ConfigParam` objects and ergonomic `ConfigValues` extension functions. * 3. Generates per-function R8 `-assumevalues` rules for local flags (dead-code elimination). - * 4. Generates a `GeneratedFlagRegistrar` that registers all flags with `FlagRegistry`. - * 5. Generates iOS constant-value files and xcconfig for Swift dead-code elimination. + * 4. Generates iOS constant-value files and xcconfig for Swift dead-code elimination. * * Usage in `build.gradle.kts`: * ```kotlin @@ -57,7 +55,6 @@ public class FeaturedPlugin : Plugin { } registerConfigParamTask(target, resolveTask) - registerFlagRegistrarTask(target, resolveTask) val proguardTask = registerProguardTask(target, resolveTask) registerIosConstValTask(target, resolveTask) registerXcconfigTask(target, resolveTask) @@ -94,22 +91,6 @@ public class FeaturedPlugin : Plugin { } } - private fun registerFlagRegistrarTask( - target: Project, - resolveTask: TaskProvider, - ) { - target.tasks.register(GENERATE_FLAG_REGISTRAR_TASK_NAME, GenerateFlagRegistrarTask::class.java) { task -> - task.group = "featured" - task.description = "Generates GeneratedFlagRegistrar.kt for '${target.path}'." - task.scanResultFile.set(resolveTask.flatMap { it.outputFile }) - task.packageName.set("dev.androidbroadcast.featured.generated") - task.outputFile.set( - target.layout.buildDirectory.file("generated/featured/GeneratedFlagRegistrar.kt"), - ) - task.dependsOn(resolveTask) - } - } - private fun registerProguardTask( target: Project, resolveTask: TaskProvider, diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FlagRegistrarGenerator.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FlagRegistrarGenerator.kt deleted file mode 100644 index 72c1296..0000000 --- a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FlagRegistrarGenerator.kt +++ /dev/null @@ -1,65 +0,0 @@ -package dev.androidbroadcast.featured.gradle - -/** - * Generates `GeneratedFlagRegistrar.kt` — an object with a `register()` function that - * calls `FlagRegistry.register(...)` for every flag declared in this module. - * - * The generated references point to the objects produced by [ConfigParamGenerator]: - * ```kotlin - * public object GeneratedFlagRegistrar { - * public fun register() { - * FlagRegistry.register(GeneratedLocalFlags.darkMode) - * FlagRegistry.register(GeneratedRemoteFlags.newCheckout) - * } - * } - * ``` - * - * This file is KMP-safe: it imports only `dev.androidbroadcast.featured.registry.FlagRegistry` - * which is available in `commonMain`. - */ -public object FlagRegistrarGenerator { - private const val FLAG_REGISTRY_IMPORT = "dev.androidbroadcast.featured.registry.FlagRegistry" - private const val LOCAL_FLAGS_IMPORT = - "dev.androidbroadcast.featured.generated.${LocalFlagEntry.GENERATED_LOCAL_OBJECT}" - private const val REMOTE_FLAGS_IMPORT = - "dev.androidbroadcast.featured.generated.${LocalFlagEntry.GENERATED_REMOTE_OBJECT}" - - /** - * Generates the `GeneratedFlagRegistrar.kt` source text. - * - * When [entries] is empty the `register()` body is empty but the file is still emitted - * so the compilation source set always contains a valid symbol. - * - * @param entries All flag entries for this module (local + remote). - * @param packageName Package for the generated file. - */ - public fun generate( - entries: List, - packageName: String = "dev.androidbroadcast.featured.generated", - ): String = - buildString { - appendLine("// Auto-generated by Featured Gradle Plugin — do not edit manually.") - appendLine("package $packageName") - appendLine() - appendLine("import $FLAG_REGISTRY_IMPORT") - if (entries.any { it.isLocal }) appendLine("import $LOCAL_FLAGS_IMPORT") - if (entries.any { !it.isLocal }) appendLine("import $REMOTE_FLAGS_IMPORT") - appendLine() - appendLine("public object GeneratedFlagRegistrar {") - appendLine(" /**") - appendLine(" * Registers all flags declared in this module with [FlagRegistry].") - appendLine(" * Call this once during app startup.") - appendLine(" */") - appendLine(" public fun register() {") - entries.forEach { entry -> - val ref = entry.kotlinReference - if (ref.isNotBlank()) { - appendLine(" FlagRegistry.register($ref)") - } else { - appendLine(" // TODO: register flag '${entry.key}' — property reference unavailable") - } - } - appendLine(" }") - append("}") - } -} diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateFlagRegistrarTask.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateFlagRegistrarTask.kt deleted file mode 100644 index 8917582..0000000 --- a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateFlagRegistrarTask.kt +++ /dev/null @@ -1,70 +0,0 @@ -package dev.androidbroadcast.featured.gradle - -import org.gradle.api.DefaultTask -import org.gradle.api.file.RegularFileProperty -import org.gradle.api.provider.Property -import org.gradle.api.tasks.CacheableTask -import org.gradle.api.tasks.Input -import org.gradle.api.tasks.InputFile -import org.gradle.api.tasks.OutputFile -import org.gradle.api.tasks.PathSensitive -import org.gradle.api.tasks.PathSensitivity -import org.gradle.api.tasks.TaskAction - -/** - * Gradle task that reads the [ScanLocalFlagsTask] output file and generates a - * `GeneratedFlagRegistrar.kt` source file containing an `object GeneratedFlagRegistrar` - * with a single `register()` function that calls `FlagRegistry.register(...)` for every - * `@LocalFlag`-annotated `ConfigParam` in this module. - * - * The generated file is KMP-safe — it uses only APIs available in `commonMain`. - * - * Wire the generated source directory into the Kotlin compilation manually: - * ```kotlin - * kotlin { - * sourceSets.commonMain.get().kotlin.srcDir( - * tasks.named("generateFlagRegistrar").map { it.outputFile.get().asFile.parentFile } - * ) - * } - * ``` - */ -@CacheableTask -public abstract class GenerateFlagRegistrarTask : DefaultTask() { - /** - * The line-delimited flag report produced by [ScanLocalFlagsTask]. - * Each line has the format `key|defaultValue|type|moduleName|propertyName|ownerName`. - */ - @get:InputFile - @get:PathSensitive(PathSensitivity.NONE) - public abstract val scanResultFile: RegularFileProperty - - /** - * Kotlin package name used in the generated `GeneratedFlagRegistrar` object. - * Defaults to `"dev.androidbroadcast.featured.generated"`. - */ - @get:Input - public abstract val packageName: Property - - /** - * The generated `GeneratedFlagRegistrar.kt` file. - * Written to `/build/generated/featured/GeneratedFlagRegistrar.kt`. - */ - @get:OutputFile - public abstract val outputFile: RegularFileProperty - - @TaskAction - public fun generate() { - val entries = scanResultFile.parseLocalFlagEntries() - val source = FlagRegistrarGenerator.generate(entries, packageName.get()) - - val out = outputFile.get().asFile - out.parentFile?.mkdirs() - out.writeText(source) - - if (entries.isEmpty()) { - logger.lifecycle("[featured] No flags declared in featured { } DSL — GeneratedFlagRegistrar.register() is empty.") - } else { - logger.lifecycle("[featured] Generated FlagRegistrar with ${entries.size} registration(s) → ${out.path}") - } - } -} diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/LocalFlagEntry.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/LocalFlagEntry.kt index 503b09a..a4b072c 100644 --- a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/LocalFlagEntry.kt +++ b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/LocalFlagEntry.kt @@ -38,21 +38,6 @@ public data class LocalFlagEntry( */ public val isEnum: Boolean get() = '.' in type - /** - * Returns the Kotlin reference used in the generated `FlagRegistry.register(...)` call. - * - * - Local flags: `"GeneratedLocalFlags.propertyName"` - * - Remote flags: `"GeneratedRemoteFlags.propertyName"` - * - Blank when [propertyName] is empty (legacy data without property information). - */ - public val kotlinReference: String - get() = - when { - propertyName.isBlank() -> "" - isLocal -> "$GENERATED_LOCAL_OBJECT.$propertyName" - else -> "$GENERATED_REMOTE_OBJECT.$propertyName" - } - public companion object { public const val FLAG_TYPE_LOCAL: String = "local" public const val FLAG_TYPE_REMOTE: String = "remote" diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ResolveFlagsTask.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ResolveFlagsTask.kt index 6f195f0..dbe2fae 100644 --- a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ResolveFlagsTask.kt +++ b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ResolveFlagsTask.kt @@ -18,8 +18,8 @@ import org.gradle.api.tasks.TaskAction * * where `propertyName` is the camelCase conversion of `key` (e.g. `dark_mode` → `darkMode`). * - * Downstream tasks ([GenerateFlagRegistrarTask], [GenerateProguardRulesTask], - * [GenerateConfigParamTask], etc.) declare [outputFile] as their `@InputFile` to + * Downstream tasks ([GenerateProguardRulesTask], [GenerateConfigParamTask], etc.) + * declare [outputFile] as their `@InputFile` to * establish a proper task dependency and enable configuration-cache compatibility. */ @CacheableTask diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FlagRegistrarGeneratorTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FlagRegistrarGeneratorTest.kt deleted file mode 100644 index 3cab987..0000000 --- a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FlagRegistrarGeneratorTest.kt +++ /dev/null @@ -1,143 +0,0 @@ -package dev.androidbroadcast.featured.gradle - -import kotlin.test.Test -import kotlin.test.assertContains -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -class FlagRegistrarGeneratorTest { - @Test - fun `generates package declaration`() { - val source = FlagRegistrarGenerator.generate(emptyList(), packageName = "com.example.generated") - assertContains(source, "package com.example.generated") - } - - @Test - fun `generates FlagRegistry import`() { - val source = FlagRegistrarGenerator.generate(emptyList()) - assertContains(source, "import dev.androidbroadcast.featured.registry.FlagRegistry") - } - - @Test - fun `generates public GeneratedFlagRegistrar object`() { - val source = FlagRegistrarGenerator.generate(emptyList()) - assertContains(source, "public object GeneratedFlagRegistrar") - } - - @Test - fun `generates public register function`() { - val source = FlagRegistrarGenerator.generate(emptyList()) - assertContains(source, "public fun register()") - } - - @Test - fun `generates empty register body when no entries`() { - val source = FlagRegistrarGenerator.generate(emptyList()) - assertFalse(source.contains("FlagRegistry.register("), "No register calls for empty entries") - } - - @Test - fun `generates GeneratedLocalFlags import for local flag`() { - val entries = listOf(localEntry("dark_mode", "darkMode")) - val source = FlagRegistrarGenerator.generate(entries) - assertContains(source, "import dev.androidbroadcast.featured.generated.${LocalFlagEntry.GENERATED_LOCAL_OBJECT}") - } - - @Test - fun `generates GeneratedRemoteFlags import for remote flag`() { - val entries = listOf(remoteEntry("promo_banner", "promoBanner")) - val source = FlagRegistrarGenerator.generate(entries) - assertContains(source, "import dev.androidbroadcast.featured.generated.${LocalFlagEntry.GENERATED_REMOTE_OBJECT}") - } - - @Test - fun `does not generate local import when no local flags`() { - val entries = listOf(remoteEntry("promo", "promo")) - val source = FlagRegistrarGenerator.generate(entries) - assertFalse(source.contains(LocalFlagEntry.GENERATED_LOCAL_OBJECT + "\n")) - } - - @Test - fun `generates register call referencing GeneratedLocalFlags for local flag`() { - val entries = listOf(localEntry("dark_mode", "darkMode")) - val source = FlagRegistrarGenerator.generate(entries) - assertContains(source, "FlagRegistry.register(${LocalFlagEntry.GENERATED_LOCAL_OBJECT}.darkMode)") - } - - @Test - fun `generates register call referencing GeneratedRemoteFlags for remote flag`() { - val entries = listOf(remoteEntry("promo_banner", "promoBanner")) - val source = FlagRegistrarGenerator.generate(entries) - assertContains(source, "FlagRegistry.register(${LocalFlagEntry.GENERATED_REMOTE_OBJECT}.promoBanner)") - } - - @Test - fun `generates register calls for mixed local and remote entries`() { - val entries = - listOf( - localEntry("dark_mode", "darkMode"), - remoteEntry("promo_banner", "promoBanner"), - ) - val source = FlagRegistrarGenerator.generate(entries) - assertContains(source, "FlagRegistry.register(${LocalFlagEntry.GENERATED_LOCAL_OBJECT}.darkMode)") - assertContains(source, "FlagRegistry.register(${LocalFlagEntry.GENERATED_REMOTE_OBJECT}.promoBanner)") - } - - @Test - fun `generates TODO comment for blank property name`() { - val entries = - listOf( - LocalFlagEntry(key = "legacy_flag", defaultValue = "false", type = "Boolean", moduleName = ":app"), - ) - val source = FlagRegistrarGenerator.generate(entries) - assertContains(source, "// TODO: register flag 'legacy_flag'") - assertFalse(source.contains("FlagRegistry.register()")) - } - - @Test - fun `generated source contains auto-generated comment`() { - val source = FlagRegistrarGenerator.generate(emptyList()) - assertContains(source, "Auto-generated by Featured Gradle Plugin") - } - - @Test - fun `uses default package name when not specified`() { - val source = FlagRegistrarGenerator.generate(emptyList()) - assertContains(source, "package dev.androidbroadcast.featured.generated") - } - - @Test - fun `generated source has balanced braces`() { - val entries = listOf(localEntry("flag", "flag")) - val source = FlagRegistrarGenerator.generate(entries) - val open = source.count { it == '{' } - val close = source.count { it == '}' } - assertTrue(open == close && open >= 2, "Expected balanced braces, open=$open close=$close") - } - - // ── helpers ─────────────────────────────────────────────────────────────── - - private fun localEntry( - key: String, - propertyName: String, - ) = LocalFlagEntry( - key = key, - defaultValue = "false", - type = "Boolean", - moduleName = ":app", - propertyName = propertyName, - flagType = LocalFlagEntry.FLAG_TYPE_LOCAL, - ) - - private fun remoteEntry( - key: String, - propertyName: String, - ) = LocalFlagEntry( - key = key, - defaultValue = "false", - type = "Boolean", - moduleName = ":app", - propertyName = propertyName, - flagType = LocalFlagEntry.FLAG_TYPE_REMOTE, - ) -} diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/GenerateFlagRegistrarTaskRegistrationTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/GenerateFlagRegistrarTaskRegistrationTest.kt deleted file mode 100644 index 4993e8f..0000000 --- a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/GenerateFlagRegistrarTaskRegistrationTest.kt +++ /dev/null @@ -1,110 +0,0 @@ -package dev.androidbroadcast.featured.gradle - -import org.gradle.testfixtures.ProjectBuilder -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertTrue - -class GenerateFlagRegistrarTaskRegistrationTest { - @Test - fun `plugin registers generateFlagRegistrar task`() { - val project = ProjectBuilder.builder().build() - project.plugins.apply("dev.androidbroadcast.featured") - - assertNotNull( - project.tasks.findByName(GENERATE_FLAG_REGISTRAR_TASK_NAME), - "Expected '$GENERATE_FLAG_REGISTRAR_TASK_NAME' task to be registered by the plugin", - ) - } - - @Test - fun `generateFlagRegistrar task is of correct type`() { - val project = ProjectBuilder.builder().build() - project.plugins.apply("dev.androidbroadcast.featured") - - val task = project.tasks.findByName(GENERATE_FLAG_REGISTRAR_TASK_NAME) - assertNotNull(task) - assertTrue( - task is GenerateFlagRegistrarTask, - "Expected task type GenerateFlagRegistrarTask but was ${task::class.simpleName}", - ) - } - - @Test - fun `generateFlagRegistrar task is in featured group`() { - val project = ProjectBuilder.builder().build() - project.plugins.apply("dev.androidbroadcast.featured") - - val task = project.tasks.findByName(GENERATE_FLAG_REGISTRAR_TASK_NAME) - assertNotNull(task) - assertEquals( - "featured", - task.group, - "Expected task group 'featured' but was '${task.group}'", - ) - } - - @Test - fun `generateFlagRegistrar task has outputFile configured`() { - val project = ProjectBuilder.builder().build() - project.plugins.apply("dev.androidbroadcast.featured") - - val task = project.tasks.findByName(GENERATE_FLAG_REGISTRAR_TASK_NAME) as? GenerateFlagRegistrarTask - assertNotNull(task) - assertTrue( - task.outputFile.isPresent, - "Expected outputFile to be configured on GenerateFlagRegistrarTask", - ) - } - - @Test - fun `generateFlagRegistrar task has packageName configured`() { - val project = ProjectBuilder.builder().build() - project.plugins.apply("dev.androidbroadcast.featured") - - val task = project.tasks.findByName(GENERATE_FLAG_REGISTRAR_TASK_NAME) as? GenerateFlagRegistrarTask - assertNotNull(task) - assertTrue( - task.packageName.isPresent, - "Expected packageName to be configured on GenerateFlagRegistrarTask", - ) - assertEquals( - "dev.androidbroadcast.featured.generated", - task.packageName.get(), - "Expected default package name 'dev.androidbroadcast.featured.generated'", - ) - } - - @Test - fun `generateFlagRegistrar task depends on resolveFeatureFlags task`() { - val project = ProjectBuilder.builder().build() - project.plugins.apply("dev.androidbroadcast.featured") - - val generateTask = project.tasks.findByName(GENERATE_FLAG_REGISTRAR_TASK_NAME) - assertNotNull(generateTask) - val scanTask = project.tasks.findByName(RESOLVE_FLAGS_TASK_NAME) - assertNotNull(scanTask) - assertTrue( - generateTask.taskDependencies.getDependencies(generateTask).contains(scanTask), - "Expected '$GENERATE_FLAG_REGISTRAR_TASK_NAME' to depend on '$RESOLVE_FLAGS_TASK_NAME'", - ) - } - - @Test - fun `generateFlagRegistrar outputFile is inside build generated featured directory`() { - val project = ProjectBuilder.builder().build() - project.plugins.apply("dev.androidbroadcast.featured") - - val task = project.tasks.findByName(GENERATE_FLAG_REGISTRAR_TASK_NAME) as? GenerateFlagRegistrarTask - assertNotNull(task) - val outputPath = - task.outputFile - .get() - .asFile.path - assertTrue( - outputPath.contains("generated/featured"), - "Expected outputFile inside 'generated/featured' directory, got: $outputPath", - ) - } -} diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/LocalFlagEntryKotlinReferenceTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/LocalFlagEntryKotlinReferenceTest.kt deleted file mode 100644 index 49a6762..0000000 --- a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/LocalFlagEntryKotlinReferenceTest.kt +++ /dev/null @@ -1,80 +0,0 @@ -package dev.androidbroadcast.featured.gradle - -import kotlin.test.Test -import kotlin.test.assertEquals - -class LocalFlagEntryKotlinReferenceTest { - @Test - fun `kotlinReference for local flag uses GeneratedLocalFlags object`() { - val entry = - LocalFlagEntry( - key = "dark_mode", - defaultValue = "false", - type = "Boolean", - moduleName = ":app", - propertyName = "darkMode", - flagType = LocalFlagEntry.FLAG_TYPE_LOCAL, - ) - assertEquals("${LocalFlagEntry.GENERATED_LOCAL_OBJECT}.darkMode", entry.kotlinReference) - } - - @Test - fun `kotlinReference for remote flag uses GeneratedRemoteFlags object`() { - val entry = - LocalFlagEntry( - key = "promo_banner", - defaultValue = "false", - type = "Boolean", - moduleName = ":app", - propertyName = "promoBanner", - flagType = LocalFlagEntry.FLAG_TYPE_REMOTE, - ) - assertEquals("${LocalFlagEntry.GENERATED_REMOTE_OBJECT}.promoBanner", entry.kotlinReference) - } - - @Test - fun `kotlinReference returns empty string when propertyName is blank`() { - val entry = - LocalFlagEntry( - key = "legacy", - defaultValue = "false", - type = "Boolean", - moduleName = ":app", - propertyName = "", - flagType = LocalFlagEntry.FLAG_TYPE_LOCAL, - ) - assertEquals("", entry.kotlinReference) - } - - @Test - fun `kotlinReference returns empty string for default-constructed entry`() { - val entry = LocalFlagEntry(key = "k", defaultValue = "v", type = "String", moduleName = ":mod") - assertEquals("", entry.kotlinReference) - } - - @Test - fun `isLocal is true for local flagType`() { - val entry = - LocalFlagEntry( - key = "k", - defaultValue = "v", - type = "String", - moduleName = ":mod", - flagType = LocalFlagEntry.FLAG_TYPE_LOCAL, - ) - assertEquals(true, entry.isLocal) - } - - @Test - fun `isLocal is false for remote flagType`() { - val entry = - LocalFlagEntry( - key = "k", - defaultValue = "v", - type = "String", - moduleName = ":mod", - flagType = LocalFlagEntry.FLAG_TYPE_REMOTE, - ) - assertEquals(false, entry.isLocal) - } -} diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/LocalFlagEntryTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/LocalFlagEntryTest.kt new file mode 100644 index 0000000..1c5651e --- /dev/null +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/LocalFlagEntryTest.kt @@ -0,0 +1,32 @@ +package dev.androidbroadcast.featured.gradle + +import kotlin.test.Test +import kotlin.test.assertEquals + +class LocalFlagEntryTest { + @Test + fun `isLocal is true for local flagType`() { + val entry = + LocalFlagEntry( + key = "k", + defaultValue = "v", + type = "String", + moduleName = ":mod", + flagType = LocalFlagEntry.FLAG_TYPE_LOCAL, + ) + assertEquals(true, entry.isLocal) + } + + @Test + fun `isLocal is false for remote flagType`() { + val entry = + LocalFlagEntry( + key = "k", + defaultValue = "v", + type = "String", + moduleName = ":mod", + flagType = LocalFlagEntry.FLAG_TYPE_REMOTE, + ) + assertEquals(false, entry.isLocal) + } +} diff --git a/featured-registry/CLAUDE.md b/featured-registry/CLAUDE.md deleted file mode 100644 index 571191f..0000000 --- a/featured-registry/CLAUDE.md +++ /dev/null @@ -1,9 +0,0 @@ -# featured-registry - -See [README.md](README.md) for usage. - -## Commands - -```bash -./gradlew :featured-registry:allTests -``` diff --git a/featured-registry/README.md b/featured-registry/README.md deleted file mode 100644 index cce2027..0000000 --- a/featured-registry/README.md +++ /dev/null @@ -1,19 +0,0 @@ -# featured-registry - -Runtime registry that tracks all `ConfigValue` instances across all modules in the app. - -Used by `featured-debug-ui` to enumerate every flag without manual registration. - -## How it works - -The `featured-gradle-plugin` task `generateFlagRegistrar` generates a `FlagRegistrar` class per module at build time. Each registrar registers its module's params into `FlagRegistry` at app startup. - -## Usage - -Add as `debugImplementation` — not needed in release builds. - -```kotlin -debugImplementation("dev.androidbroadcast.featured:featured-registry") -``` - -Pair with `featured-debug-ui`. No manual setup required beyond applying the Gradle plugin. diff --git a/featured-registry/build.gradle.kts b/featured-registry/build.gradle.kts deleted file mode 100644 index 559c71a..0000000 --- a/featured-registry/build.gradle.kts +++ /dev/null @@ -1,143 +0,0 @@ -import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi -import org.jetbrains.kotlin.gradle.dsl.JvmTarget - -plugins { - alias(libs.plugins.kotlinMultiplatform) - alias(libs.plugins.androidKmpLibrary) - alias(libs.plugins.kover) - alias(libs.plugins.mavenPublish) - alias(libs.plugins.dokka) -} - -kotlin { - explicitApi() - jvmToolchain(21) - - compilerOptions { - freeCompilerArgs.add("-Xexpect-actual-classes") - } - - android { - namespace = "dev.androidbroadcast.featured.registry" - compileSdk = - libs.versions.android.compileSdk - .get() - .toInt() - minSdk = - libs.versions.android.minSdk - .get() - .toInt() - compilerOptions { - jvmTarget.set(JvmTarget.JVM_21) - } - } - - listOf( - iosX64(), - iosArm64(), - iosSimulatorArm64(), - ).forEach { iosTarget -> - iosTarget.binaries.framework { - baseName = "FeaturedRegistry" - isStatic = true - } - } - - jvm() - - @OptIn(ExperimentalKotlinGradlePluginApi::class) - applyDefaultHierarchyTemplate { - common { - group("jvmCommon") { - withJvm() - withCompilations { it.target.name == "android" } - } - group("native") { - withIosX64() - withIosArm64() - withIosSimulatorArm64() - } - } - } - - sourceSets { - commonMain.dependencies { - api(projects.core) - } - - @Suppress("unused") - val commonTest by getting { - dependencies { - implementation(libs.kotlin.test) - } - } - } -} - -mavenPublishing { - publishToMavenCentral() - signAllPublications() - coordinates( - groupId = "dev.androidbroadcast.featured", - artifactId = "featured-registry", - ) - pom { - name.set("Featured Registry") - description.set("Registry module for Featured – runtime flag registration and lookup for KMP") - inceptionYear.set("2024") - url.set("https://github.com/AndroidBroadcast/Featured") - licenses { - license { - name.set("MIT License") - url.set("https://opensource.org/licenses/MIT") - distribution.set("repo") - } - } - developers { - developer { - id.set("androidbroadcast") - name.set("Kirill Rozov") - url.set("https://github.com/androidbroadcast") - } - } - scm { - url.set("https://github.com/AndroidBroadcast/Featured") - connection.set("scm:git:git://github.com/AndroidBroadcast/Featured.git") - developerConnection.set("scm:git:ssh://git@github.com/AndroidBroadcast/Featured.git") - } - } -} - -kover { - reports { - filters { - excludes { - classes("*Test*", "*Mock*", "*Fake*") - } - } - - total { - html { - onCheck = false - } - - xml { - onCheck = false - } - - log { - onCheck = true - header = "Code coverage summary for :featured-registry module" - format = "Line coverage: %" - } - - verify { - onCheck = true - - rule { - minBound(85) - } - } - } - } -} diff --git a/featured-registry/src/commonMain/kotlin/dev/androidbroadcast/featured/registry/FlagRegistry.kt b/featured-registry/src/commonMain/kotlin/dev/androidbroadcast/featured/registry/FlagRegistry.kt deleted file mode 100644 index 4045a8e..0000000 --- a/featured-registry/src/commonMain/kotlin/dev/androidbroadcast/featured/registry/FlagRegistry.kt +++ /dev/null @@ -1,34 +0,0 @@ -package dev.androidbroadcast.featured.registry - -import dev.androidbroadcast.featured.ConfigParam - -/** - * Central registry that collects all [ConfigParam] instances across feature modules. - * Powers debug UI auto-discovery of available feature flags. - * - * Thread-safe: registration and retrieval use platform-specific synchronization - * (a lock on JVM/Android, CAS-based updates on Native/iOS). - */ -public object FlagRegistry { - private val delegate = FlagRegistryDelegate() - - /** - * Registers a [ConfigParam] with the registry. - * Duplicate registrations (same param by key equality) are silently ignored. - */ - public fun register(param: ConfigParam<*>) { - delegate.register(param) - } - - /** - * Returns an immutable snapshot of all currently registered [ConfigParam] instances. - */ - public fun all(): List> = delegate.all() - - /** - * Clears all registered params. Intended for use in tests only. - */ - internal fun reset() { - delegate.reset() - } -} diff --git a/featured-registry/src/commonMain/kotlin/dev/androidbroadcast/featured/registry/FlagRegistryDelegate.kt b/featured-registry/src/commonMain/kotlin/dev/androidbroadcast/featured/registry/FlagRegistryDelegate.kt deleted file mode 100644 index c76fdaf..0000000 --- a/featured-registry/src/commonMain/kotlin/dev/androidbroadcast/featured/registry/FlagRegistryDelegate.kt +++ /dev/null @@ -1,11 +0,0 @@ -package dev.androidbroadcast.featured.registry - -import dev.androidbroadcast.featured.ConfigParam - -internal expect class FlagRegistryDelegate() { - fun register(param: ConfigParam<*>) - - fun all(): List> - - fun reset() -} diff --git a/featured-registry/src/commonTest/kotlin/dev/androidbroadcast/featured/registry/FlagRegistryTest.kt b/featured-registry/src/commonTest/kotlin/dev/androidbroadcast/featured/registry/FlagRegistryTest.kt deleted file mode 100644 index abf5477..0000000 --- a/featured-registry/src/commonTest/kotlin/dev/androidbroadcast/featured/registry/FlagRegistryTest.kt +++ /dev/null @@ -1,64 +0,0 @@ -package dev.androidbroadcast.featured.registry - -import dev.androidbroadcast.featured.ConfigParam -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -class FlagRegistryTest { - @BeforeTest - fun setUp() { - FlagRegistry.reset() - } - - @Test - fun registeredParamAppearsInAll() { - val param = ConfigParam(key = "flag_a", defaultValue = true) - FlagRegistry.register(param) - assertTrue(FlagRegistry.all().contains(param)) - } - - @Test - fun allReturnsAllRegisteredParams() { - val p1 = ConfigParam(key = "flag_b", defaultValue = false) - val p2 = ConfigParam(key = "flag_c", defaultValue = 42) - FlagRegistry.register(p1) - FlagRegistry.register(p2) - val all = FlagRegistry.all() - assertEquals(2, all.size) - assertTrue(all.contains(p1)) - assertTrue(all.contains(p2)) - } - - @Test - fun registeringDuplicateKeyDoesNotDuplicateEntry() { - val param = ConfigParam(key = "flag_d", defaultValue = "hello") - FlagRegistry.register(param) - FlagRegistry.register(param) - assertEquals(1, FlagRegistry.all().size) - } - - @Test - fun allReturnsEmptyWhenNothingRegistered() { - assertTrue(FlagRegistry.all().isEmpty()) - } - - @Test - fun registeringSameKeyWithDifferentDefaultValueDoesNotDuplicate() { - val param1 = ConfigParam(key = "flag_g", defaultValue = "first") - val param2 = ConfigParam(key = "flag_g", defaultValue = "second") - FlagRegistry.register(param1) - FlagRegistry.register(param2) - assertEquals(1, FlagRegistry.all().size) - } - - @Test - fun allReturnsImmutableSnapshot() { - val param = ConfigParam(key = "flag_e", defaultValue = 1) - FlagRegistry.register(param) - val snapshot = FlagRegistry.all() - FlagRegistry.register(ConfigParam(key = "flag_f", defaultValue = 2)) - assertEquals(1, snapshot.size) - } -} diff --git a/featured-registry/src/iosMain/kotlin/dev/androidbroadcast/featured/registry/FlagRegistryDelegate.kt b/featured-registry/src/iosMain/kotlin/dev/androidbroadcast/featured/registry/FlagRegistryDelegate.kt deleted file mode 100644 index d53a8eb..0000000 --- a/featured-registry/src/iosMain/kotlin/dev/androidbroadcast/featured/registry/FlagRegistryDelegate.kt +++ /dev/null @@ -1,26 +0,0 @@ -package dev.androidbroadcast.featured.registry - -import dev.androidbroadcast.featured.ConfigParam -import kotlin.concurrent.AtomicReference - -internal actual class FlagRegistryDelegate actual constructor() { - // AtomicReference provides safe publication on Kotlin/Native new memory model. - // Copy-on-write: every register() replaces the list atomically via CAS. - private val paramsRef = AtomicReference>>(emptyList()) - - actual fun register(param: ConfigParam<*>) { - // Spin-loop CAS: add param if no entry with the same key exists. - while (true) { - val current = paramsRef.value - if (current.any { it.key == param.key }) return - val next = current + param - if (paramsRef.compareAndSet(current, next)) return - } - } - - actual fun all(): List> = paramsRef.value.toList() - - actual fun reset() { - paramsRef.value = emptyList() - } -} diff --git a/featured-registry/src/jvmCommonMain/kotlin/dev/androidbroadcast/featured/registry/FlagRegistryDelegate.kt b/featured-registry/src/jvmCommonMain/kotlin/dev/androidbroadcast/featured/registry/FlagRegistryDelegate.kt deleted file mode 100644 index b738de3..0000000 --- a/featured-registry/src/jvmCommonMain/kotlin/dev/androidbroadcast/featured/registry/FlagRegistryDelegate.kt +++ /dev/null @@ -1,22 +0,0 @@ -package dev.androidbroadcast.featured.registry - -import dev.androidbroadcast.featured.ConfigParam -import java.util.concurrent.locks.ReentrantLock -import kotlin.concurrent.withLock - -internal actual class FlagRegistryDelegate actual constructor() { - private val lock = ReentrantLock() - - // Keyed by ConfigParam.key to guarantee one entry per key across platforms. - private val params: LinkedHashMap> = LinkedHashMap() - - actual fun register(param: ConfigParam<*>) { - lock.withLock { params.putIfAbsent(param.key, param) } - } - - actual fun all(): List> = lock.withLock { params.values.toList() } - - actual fun reset() { - lock.withLock { params.clear() } - } -} diff --git a/sample/android-app/src/main/kotlin/dev/androidbroadcast/featured/sample/MainActivity.kt b/sample/android-app/src/main/kotlin/dev/androidbroadcast/featured/sample/MainActivity.kt index 7156e6b..817588d 100644 --- a/sample/android-app/src/main/kotlin/dev/androidbroadcast/featured/sample/MainActivity.kt +++ b/sample/android-app/src/main/kotlin/dev/androidbroadcast/featured/sample/MainActivity.kt @@ -12,21 +12,18 @@ import androidx.compose.runtime.setValue import dev.androidbroadcast.featured.CheckoutVariant import dev.androidbroadcast.featured.ConfigValues import dev.androidbroadcast.featured.SampleApp +import dev.androidbroadcast.featured.SampleFeatureFlags import dev.androidbroadcast.featured.datastore.DataStoreConfigValueProvider import dev.androidbroadcast.featured.datastore.registerConverter import dev.androidbroadcast.featured.debugui.FeatureFlagsDebugScreen import dev.androidbroadcast.featured.enumConverter import dev.androidbroadcast.featured.platform.defaultLocalProvider -import dev.androidbroadcast.featured.registerSampleFlags 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 { - // Populate FlagRegistry so FeatureFlagsDebugScreen can discover all flags via FlagRegistry.all(). - registerSampleFlags() - val localProvider = defaultLocalProvider(applicationContext) // DataStore only handles primitives natively; register a converter so that the // enum-typed checkoutVariant flag can be persisted and observed without throwing. @@ -43,7 +40,7 @@ class MainActivity : ComponentActivity() { if (showDebug) { BackHandler { showDebug = false } - FeatureFlagsDebugScreen(configValues = configValues) + FeatureFlagsDebugScreen(configValues = configValues, registry = SampleFeatureFlags.all) } else { SampleApp( configValues = configValues, diff --git a/sample/shared/build.gradle.kts b/sample/shared/build.gradle.kts index 9274e79..d2867b2 100644 --- a/sample/shared/build.gradle.kts +++ b/sample/shared/build.gradle.kts @@ -54,7 +54,6 @@ kotlin { // the public signatures of SampleApp / SampleViewModel — must be api to compile // downstream consumers like :sample:desktop. Pre-existing leak from #182. api(project(":core")) - implementation(project(":featured-registry")) } } } diff --git a/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleApp.kt b/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleApp.kt index a91be4c..56a9cc0 100644 --- a/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleApp.kt +++ b/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleApp.kt @@ -4,22 +4,6 @@ package dev.androidbroadcast.featured import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import dev.androidbroadcast.featured.registry.FlagRegistry - -/** - * Registers all [SampleFeatureFlags] with [FlagRegistry] so that [FeatureFlagsDebugScreen] - * can discover them via [FlagRegistry.all]. Call once on application start before opening - * the debug UI. Duplicate calls are safe — the registry ignores already-registered params. - */ -public fun registerSampleFlags() { - listOf( - SampleFeatureFlags.mainButtonRed, - SampleFeatureFlags.newFeatureSectionEnabled, - SampleFeatureFlags.newCheckout, - SampleFeatureFlags.promoBannerEnabled, - SampleFeatureFlags.checkoutVariant, - ).forEach(FlagRegistry::register) -} /** * Root composable for the sample application. diff --git a/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleFeatureFlags.kt b/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleFeatureFlags.kt index b75c4f5..757a3bd 100644 --- a/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleFeatureFlags.kt +++ b/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleFeatureFlags.kt @@ -41,6 +41,7 @@ public enum class CheckoutVariant { * * The sample module is part of the library's own build and cannot apply the plugin * to itself, so all flags are declared manually here for demonstration purposes. + * [SampleFeatureFlags.all] is the single source of truth for the Debug UI registry. */ public object SampleFeatureFlags { public val mainButtonRed: ConfigParam = @@ -81,4 +82,13 @@ public object SampleFeatureFlags { description = "Controls which checkout flow variant is shown to the user", category = "checkout", ) + + public val all: List> = + listOf( + mainButtonRed, + newFeatureSectionEnabled, + newCheckout, + promoBannerEnabled, + checkoutVariant, + ) } diff --git a/settings.gradle.kts b/settings.gradle.kts index 201871b..1b45cb1 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -42,7 +42,6 @@ include(":sample:android-app") include(":sample:desktop") include(":core") include(":featured-compose") -include(":featured-registry") include(":featured-debug-ui") include(":featured-testing") From 9055ad72ca9d088f8bbad1382ba817914cb726bb Mon Sep 17 00:00:00 2001 From: Kirill Rozov Date: Wed, 20 May 2026 10:12:24 +0300 Subject: [PATCH 10/15] Multi-module sample showcasing Featured aggregator plugin (#200) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Extract featured-gradle-plugin to build-logic included build Moves featured-gradle-plugin/ to build-logic/featured-gradle-plugin/ and wires it via pluginManagement.includeBuild("build-logic") so subprojects in the main build can apply id("dev.androidbroadcast.featured") without a version coordinate. Unblocks the multi-module sample restructure that needs the plugin available to feature modules in the same build. build-logic has its own settings file with the version catalog reused from the parent (from(files("../gradle/libs.versions.toml"))) and a gradle.beforeProject hook that propagates parent gradle.properties (notably VERSION_NAME) so the plugin still publishes with the project version instead of "unspecified". Root publishToMavenCentral / publishToMavenLocal proxy tasks delegate to the included build so the publish workflow keeps a single entrypoint. The plugin constraint is dropped from featured-bom: a java-platform BOM only constrains dependency resolution and is irrelevant to plugins applied through pluginManagement. Co-Authored-By: Claude Opus 4.7 * Split sample flags into three KMP feature modules Introduces :sample:feature-checkout, :sample:feature-promotions, and :sample:feature-ui as canonical demo of the Featured aggregator workflow: each module applies the plugin, declares its own flags via the featured { localFlags / remoteFlags } DSL, and exposes a public observe-bridge file (Flow-shaped) on ConfigValues. The bridges are the pedagogical surface — GeneratedLocalFlags/GeneratedRemoteFlags contain ConfigParam instances that consumers compose into Flow via observe(...).map { it.value }. Fixes a latent codegen bug surfaced by the first cross-platform use of GeneratedFlagExtensions: extensions were emitted non-suspend but ConfigValues.getValue is suspend, so any Kotlin/Native or AndroidMain compile failed. ExtensionFunctionGenerator now emits suspend extensions, ConfigParamGenerator widens GeneratedLocalFlags / GeneratedRemoteFlags to public so they can be referenced from observer bridges in other modules, and the generated extensions file name now includes a module- derived suffix (e.g. GeneratedFlagExtensionsSampleFeatureCheckout.kt) so each module's JVM class name is unique without relying on @file:JvmName (which doesn't resolve in commonMain on KMP iOS targets). GenerateConfigParamTask now wipes its output directory on each run so file-name changes don't leave stale generated sources behind. Out of scope (tracked as follow-up): ProguardRulesGenerator and featured-shrinker-tests still target the legacy non-suspend extension signature; R8 per-function DCE via -assumevalues silently stops matching the new suspend JVM signature until that subsystem is reworked. Co-Authored-By: Claude Opus 4.7 * Convert :sample:shared to Featured aggregator Applies dev.androidbroadcast.featured.application to :sample:shared and replaces the hand-written SampleFeatureFlags with the plugin- generated GeneratedFeaturedRegistry.all sourced via featuredAggregation from :sample:feature-checkout, :sample:feature-promotions, and :sample:feature-ui. The three feature modules are also added as api dependencies because CheckoutVariant and the observe-bridge extensions defined in them appear in the public surface that downstream sample app modules (:sample:android-app, :sample:desktop) consume directly. The iOS framework blocks export the three modules so Swift sees CheckoutVariant without needing a separate Pod. generateFeaturedRegistry produces an object with five ConfigParam entries (sorted by modulePath then key) which the debug screen and ViewModel now consume in Phase 5. Co-Authored-By: Claude Opus 4.7 * Migrate sample consumers to observe-bridges; module-suffix flag objects Phase 5 of PR D rewires the sample ViewModel and screens to consume flags through the new observe-bridge pattern instead of the deleted hand-written SampleFeatureFlags. SampleViewModel now exposes five StateFlow built from the per-module Flow bridges with stateIn defaults that mirror each feature module's DSL default, plus four suspend-launching setters routed through ConfigValues.override. FeaturedSample picks up CheckoutVariant from its new home in :sample:feature-checkout; MainActivity points FeatureFlagsDebugScreen at GeneratedFeaturedRegistry.all. A second collision surfaced during the first Android assembleDebug: ConfigParamGenerator emitted a fixed-name GeneratedLocalFlags / GeneratedRemoteFlags object in the same package across every module, so feature-checkout and feature-ui produced duplicate dex classes under the same FQN. ConfigParamGenerator now names both the file and the public object with a module-derived suffix (e.g. GeneratedLocalFlagsSampleFeatureCheckout), matching the GeneratedFlagExtensions fix from Phase 3. The three observe-bridge files reference the suffixed objects accordingly. The matching iOS link crash in ObjCExportCodeGenerator was the side-effect of the previous implementation-vs-api wiring: K/N could not resolve the concrete type adapter for ConfigParam because the feature klibs weren't on the link path. Phase 4's api(project(":sample:feature-*")) switch and Phase 3b's matching visibility/api widening jointly fix it — a clean linkDebugFrameworkIosSimulatorArm64 now succeeds without changes here. Co-Authored-By: Claude Opus 4.7 * Add enum override input to FeatureFlagsDebugScreen ConfigParam now carries enumConstants: List? so the debug UI can discover the legal values without runtime reflection. Plugin codegen fills the field for both per-module objects and the aggregated GeneratedFeaturedRegistry by emitting kotlin.enumValues() .toList() for any flag with enumTypeFqn in the manifest. FeatureFlagsDebugScreen dispatches to a new EnumDropdown (ExposedDropdownMenuBox + DropdownMenuItem) whenever param.enumConstants != null, mirroring the boolean/string/int input treatment: current value visible in the text field, options in the dropdown, source badge updates to LOCAL on selection. Boolean and scalar input paths are unchanged. Verified on Pixel 10 emulator: the sample's checkout_variant enum can be flipped to NEW_SINGLE_PAGE / NEW_MULTI_STEP via the debug screen, the main screen reacts immediately, and DataStore persistence survives both rotation and cold restart. Co-Authored-By: Claude Opus 4.7 * Document multi-module sample structure Updates the [Unreleased] CHANGELOG with the PR D shape: three sample feature modules, build-logic-extracted plugin, enum override input in the debug screen, and the module-suffixed generator filenames. Adds a sample/CLAUDE.md as the short navigational note for future sessions working on the sample (module map, observe-bridge convention, how to add a flag). The GitHub wiki pages Sample-App.md and Multi-Module- Setup.md are updated in the separate .wiki repo alongside this PR. Co-Authored-By: Claude Opus 4.7 * Drop dead listOf wrapper around iOS target declarations The three sample feature modules wrapped iosX64() / iosArm64() / iosSimulatorArm64() in a listOf(...) whose result was discarded. The constructors register the targets via side-effect; the wrapper was leftover from copying :sample:shared (which uses .forEach to configure framework binaries). In the leaf modules the wrapper reads as if a .forEach { ... } was deleted. Replace with bare statements to match the idiom used elsewhere. Co-Authored-By: Claude Opus 4.7 * Track parent gradle.properties via providers.fileContents build-logic/settings.gradle.kts read the root gradle.properties via raw java.util.Properties().load(FileInputStream), which the configuration cache does not fingerprint. After a VERSION_NAME bump the included build could publish the cached previous version. Switch to providers.fileContents(...).asText so Gradle invalidates the cache when the parent file changes. pluginManagement {} also moves to the first block as the docs require. Cover the new ConfigParam.enumConstants field in equals, hashCode, and toString with focused unit tests so the :core ≥90% line-coverage gate stays green and the equality semantics — registry dedup depends on them — are pinned. Co-Authored-By: Claude Opus 4.7 * Address review: switch generators to kotlin.enums.enumEntries cubic flagged that the codegen emits the deprecated kotlin.enumValues() helper. Replace with kotlin.enums.enumEntries() (stable since Kotlin 1.9): it returns EnumEntries — a lazy, cached List — so the trailing .toList() is no longer needed. Generated ConfigParam objects in per-module GeneratedLocalFlags* and in the aggregated GeneratedFeaturedRegistry both benefit; ConfigParam.enumConstants still types as List? unchanged. Tests pin the new emit string. Co-Authored-By: Claude Opus 4.7 --------- Co-authored-by: Claude Opus 4.7 --- CHANGELOG.md | 12 +- .../featured-gradle-plugin}/CLAUDE.md | 0 .../featured-gradle-plugin}/README.md | 0 .../featured-gradle-plugin}/build.gradle.kts | 0 .../featured/gradle/AndroidProguardWiring.kt | 0 .../featured/gradle/ConfigParamGenerator.kt | 120 +++++++++++++++++ .../gradle/ExtensionFunctionGenerator.kt | 122 ++++++++++++++++++ .../gradle/FeaturedApplicationPlugin.kt | 0 .../featured/gradle/FeaturedExtension.kt | 0 .../featured/gradle/FeaturedPlugin.kt | 0 .../featured/gradle/FlagContainer.kt | 0 .../featured/gradle/FlagEntryUtils.kt | 27 +++- .../featured/gradle/FlagSpec.kt | 0 .../gradle/GenerateConfigParamTask.kt | 25 ++-- .../gradle/GenerateIosConstValTask.kt | 0 .../gradle/GenerateProguardRulesTask.kt | 0 .../featured/gradle/GenerateXcconfigTask.kt | 0 .../featured/gradle/IosConstValGenerator.kt | 0 .../featured/gradle/LocalFlagEntry.kt | 2 - .../featured/gradle/ProguardRulesGenerator.kt | 0 .../featured/gradle/ResolveFlagsTask.kt | 0 .../featured/gradle/ScanResultParser.kt | 0 .../featured/gradle/XcconfigGenerator.kt | 0 .../gradle/aggregation/AggregationContract.kt | 0 .../GenerateFeaturedRegistryTask.kt | 0 .../GeneratedFeaturedRegistryGenerator.kt | 3 + .../gradle/manifest/FeaturedManifest.kt | 0 .../manifest/FeaturedManifestContract.kt | 0 .../manifest/GenerateFeaturedManifestTask.kt | 0 .../app/build.gradle.kts | 0 .../app/src/main/AndroidManifest.xml | 0 .../build.gradle.kts | 0 .../feature-checkout/build.gradle.kts | 0 .../src/main/AndroidManifest.xml | 0 .../feature-profile/build.gradle.kts | 0 .../src/main/AndroidManifest.xml | 0 .../gradle.properties | 0 .../settings.gradle.kts | 0 .../fixtures/android-project/build.gradle.kts | 0 .../android-project/settings.gradle.kts | 0 .../src/main/AndroidManifest.xml | 0 .../featured/testapp/CheckoutVariant.kt | 0 .../build.gradle.kts | 0 .../settings.gradle.kts | 0 .../kmp-publish-project/build.gradle.kts | 0 .../kmp-publish-project/gradle.properties | 0 .../module/build.gradle.kts | 0 .../module/src/commonMain/kotlin/.gitkeep | 0 .../kmp-publish-project/settings.gradle.kts | 0 .../app/build.gradle.kts | 0 .../app/src/main/AndroidManifest.xml | 0 .../manifest-publish-project/build.gradle.kts | 0 .../gradle.properties | 0 .../settings.gradle.kts | 0 .../gradle/ConfigParamGeneratorTest.kt | 102 ++++++++++----- .../gradle/ExtensionFunctionGeneratorTest.kt | 57 ++++---- .../gradle/FeaturedPluginIntegrationTest.kt | 0 .../featured/gradle/FeaturedPluginTest.kt | 0 .../featured/gradle/FlagContainerTest.kt | 0 .../featured/gradle/FlagEntryUtilsTest.kt | 32 +++++ ...GenerateIosConstValTaskRegistrationTest.kt | 0 ...nerateProguardRulesTaskRegistrationTest.kt | 0 .../GenerateXcconfigTaskRegistrationTest.kt | 0 .../gradle/IosConstValGeneratorTest.kt | 0 .../featured/gradle/LocalFlagEntryTest.kt | 0 .../gradle/ProguardRulesGeneratorTest.kt | 0 .../featured/gradle/XcconfigGeneratorTest.kt | 0 .../FeaturedAggregationConfigurationTest.kt | 0 ...turedAggregationDescriptorIntegrityTest.kt | 0 .../FeaturedAggregationDuplicateKeyTest.kt | 0 .../FeaturedAggregationIntegrationTest.kt | 0 .../FeaturedAggregationParseErrorTest.kt | 0 ...ateFeaturedRegistryTaskRegistrationTest.kt | 0 .../GeneratedFeaturedRegistryGeneratorTest.kt | 11 ++ .../manifest/FeaturedKmpPublicationTest.kt | 0 .../FeaturedManifestConfigurationTest.kt | 0 .../manifest/FeaturedManifestEmptyDslTest.kt | 0 .../FeaturedManifestIntegrationTest.kt | 0 .../manifest/FeaturedManifestMappingTest.kt | 0 .../FeaturedManifestSerializationTest.kt | 0 ...ateFeaturedManifestTaskRegistrationTest.kt | 0 .../gradle/manifest/TestFixtureSupport.kt | 0 build-logic/settings.gradle.kts | 61 +++++++++ build.gradle.kts | 8 ++ .../androidbroadcast/featured/ConfigParam.kt | 11 +- .../featured/ConfigParamTest.kt | 55 ++++++++ featured-bom/build.gradle.kts | 1 - .../debugui/FeatureFlagsDebugScreen.kt | 73 +++++++++++ .../featured/gradle/ConfigParamGenerator.kt | 73 ----------- .../gradle/ExtensionFunctionGenerator.kt | 88 ------------- sample/CLAUDE.md | 29 +++++ .../featured/sample/MainActivity.kt | 6 +- sample/feature-checkout/build.gradle.kts | 60 +++++++++ .../src/androidMain/AndroidManifest.xml | 2 + .../sample/checkout/CheckoutFlagObservers.kt | 17 +++ .../sample/checkout/CheckoutVariant.kt | 7 + sample/feature-promotions/build.gradle.kts | 52 ++++++++ .../src/androidMain/AndroidManifest.xml | 2 + .../promotions/PromotionsFlagObservers.kt | 15 +++ sample/feature-ui/build.gradle.kts | 56 ++++++++ .../src/androidMain/AndroidManifest.xml | 2 + .../featured/sample/ui/UiFlagObservers.kt | 19 +++ sample/shared/build.gradle.kts | 20 +++ .../featured/FeaturedSample.kt | 1 + .../featured/SampleFeatureFlags.kt | 94 -------------- .../featured/SampleViewModel.kt | 64 ++++++--- settings.gradle.kts | 5 +- 107 files changed, 987 insertions(+), 347 deletions(-) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/CLAUDE.md (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/README.md (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/build.gradle.kts (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/main/kotlin/dev/androidbroadcast/featured/gradle/AndroidProguardWiring.kt (100%) create mode 100644 build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ConfigParamGenerator.kt create mode 100644 build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ExtensionFunctionGenerator.kt rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedApplicationPlugin.kt (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedExtension.kt (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPlugin.kt (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/main/kotlin/dev/androidbroadcast/featured/gradle/FlagContainer.kt (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/main/kotlin/dev/androidbroadcast/featured/gradle/FlagEntryUtils.kt (56%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/main/kotlin/dev/androidbroadcast/featured/gradle/FlagSpec.kt (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateConfigParamTask.kt (63%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateIosConstValTask.kt (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateProguardRulesTask.kt (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateXcconfigTask.kt (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/main/kotlin/dev/androidbroadcast/featured/gradle/IosConstValGenerator.kt (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/main/kotlin/dev/androidbroadcast/featured/gradle/LocalFlagEntry.kt (93%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/main/kotlin/dev/androidbroadcast/featured/gradle/ProguardRulesGenerator.kt (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/main/kotlin/dev/androidbroadcast/featured/gradle/ResolveFlagsTask.kt (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/main/kotlin/dev/androidbroadcast/featured/gradle/ScanResultParser.kt (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/main/kotlin/dev/androidbroadcast/featured/gradle/XcconfigGenerator.kt (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/AggregationContract.kt (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GenerateFeaturedRegistryTask.kt (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GeneratedFeaturedRegistryGenerator.kt (96%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifest.kt (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestContract.kt (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/GenerateFeaturedManifestTask.kt (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/test/fixtures/aggregator-multi-module-project/app/build.gradle.kts (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/test/fixtures/aggregator-multi-module-project/app/src/main/AndroidManifest.xml (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/test/fixtures/aggregator-multi-module-project/build.gradle.kts (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/test/fixtures/aggregator-multi-module-project/feature-checkout/build.gradle.kts (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/test/fixtures/aggregator-multi-module-project/feature-checkout/src/main/AndroidManifest.xml (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/test/fixtures/aggregator-multi-module-project/feature-profile/build.gradle.kts (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/test/fixtures/aggregator-multi-module-project/feature-profile/src/main/AndroidManifest.xml (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/test/fixtures/aggregator-multi-module-project/gradle.properties (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/test/fixtures/aggregator-multi-module-project/settings.gradle.kts (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/test/fixtures/android-project/build.gradle.kts (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/test/fixtures/android-project/settings.gradle.kts (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/test/fixtures/android-project/src/main/AndroidManifest.xml (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/test/fixtures/android-project/src/main/kotlin/dev/androidbroadcast/featured/testapp/CheckoutVariant.kt (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/test/fixtures/jvm-empty-featured-project/build.gradle.kts (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/test/fixtures/jvm-empty-featured-project/settings.gradle.kts (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/test/fixtures/kmp-publish-project/build.gradle.kts (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/test/fixtures/kmp-publish-project/gradle.properties (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/test/fixtures/kmp-publish-project/module/build.gradle.kts (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/test/fixtures/kmp-publish-project/module/src/commonMain/kotlin/.gitkeep (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/test/fixtures/kmp-publish-project/settings.gradle.kts (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/test/fixtures/manifest-publish-project/app/build.gradle.kts (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/test/fixtures/manifest-publish-project/app/src/main/AndroidManifest.xml (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/test/fixtures/manifest-publish-project/build.gradle.kts (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/test/fixtures/manifest-publish-project/gradle.properties (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/test/fixtures/manifest-publish-project/settings.gradle.kts (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/ConfigParamGeneratorTest.kt (60%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/ExtensionFunctionGeneratorTest.kt (74%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPluginIntegrationTest.kt (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPluginTest.kt (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/FlagContainerTest.kt (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/FlagEntryUtilsTest.kt (65%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/GenerateIosConstValTaskRegistrationTest.kt (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/GenerateProguardRulesTaskRegistrationTest.kt (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/GenerateXcconfigTaskRegistrationTest.kt (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/IosConstValGeneratorTest.kt (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/LocalFlagEntryTest.kt (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/ProguardRulesGeneratorTest.kt (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/XcconfigGeneratorTest.kt (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationConfigurationTest.kt (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationDescriptorIntegrityTest.kt (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationDuplicateKeyTest.kt (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationIntegrationTest.kt (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationParseErrorTest.kt (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GenerateFeaturedRegistryTaskRegistrationTest.kt (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GeneratedFeaturedRegistryGeneratorTest.kt (96%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedKmpPublicationTest.kt (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestConfigurationTest.kt (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestEmptyDslTest.kt (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestIntegrationTest.kt (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestMappingTest.kt (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestSerializationTest.kt (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/GenerateFeaturedManifestTaskRegistrationTest.kt (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/TestFixtureSupport.kt (100%) create mode 100644 build-logic/settings.gradle.kts delete mode 100644 featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ConfigParamGenerator.kt delete mode 100644 featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ExtensionFunctionGenerator.kt create mode 100644 sample/CLAUDE.md create mode 100644 sample/feature-checkout/build.gradle.kts create mode 100644 sample/feature-checkout/src/androidMain/AndroidManifest.xml create mode 100644 sample/feature-checkout/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/checkout/CheckoutFlagObservers.kt create mode 100644 sample/feature-checkout/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/checkout/CheckoutVariant.kt create mode 100644 sample/feature-promotions/build.gradle.kts create mode 100644 sample/feature-promotions/src/androidMain/AndroidManifest.xml create mode 100644 sample/feature-promotions/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/promotions/PromotionsFlagObservers.kt create mode 100644 sample/feature-ui/build.gradle.kts create mode 100644 sample/feature-ui/src/androidMain/AndroidManifest.xml create mode 100644 sample/feature-ui/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/ui/UiFlagObservers.kt delete mode 100644 sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleFeatureFlags.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fc14e3..78fb03f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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>` 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>, 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> }` 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` now carries `enumConstants: List?` 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` specializations. ## [1.0.0-Beta1] - 2026-05-17 diff --git a/featured-gradle-plugin/CLAUDE.md b/build-logic/featured-gradle-plugin/CLAUDE.md similarity index 100% rename from featured-gradle-plugin/CLAUDE.md rename to build-logic/featured-gradle-plugin/CLAUDE.md diff --git a/featured-gradle-plugin/README.md b/build-logic/featured-gradle-plugin/README.md similarity index 100% rename from featured-gradle-plugin/README.md rename to build-logic/featured-gradle-plugin/README.md diff --git a/featured-gradle-plugin/build.gradle.kts b/build-logic/featured-gradle-plugin/build.gradle.kts similarity index 100% rename from featured-gradle-plugin/build.gradle.kts rename to build-logic/featured-gradle-plugin/build.gradle.kts diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/AndroidProguardWiring.kt b/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/AndroidProguardWiring.kt similarity index 100% rename from featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/AndroidProguardWiring.kt rename to build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/AndroidProguardWiring.kt diff --git a/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ConfigParamGenerator.kt b/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ConfigParamGenerator.kt new file mode 100644 index 0000000..9853328 --- /dev/null +++ b/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ConfigParamGenerator.kt @@ -0,0 +1,120 @@ +package dev.androidbroadcast.featured.gradle + +/** + * Generates `GeneratedLocalFlags.kt` and `GeneratedRemoteFlags.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("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, + modulePath: String, + ): Pair { + val (local, remote) = entries.partition { it.isLocal } + return generateObject(local, localObjectName(modulePath)) to + generateObject(remote, remoteObjectName(modulePath)) + } + + private fun generateObject( + entries: List, + 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\"") + 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 + } +} diff --git a/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ExtensionFunctionGenerator.kt b/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ExtensionFunctionGenerator.kt new file mode 100644 index 0000000..c9e89f1 --- /dev/null +++ b/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ExtensionFunctionGenerator.kt @@ -0,0 +1,122 @@ +package dev.androidbroadcast.featured.gradle + +/** + * Generates the `GeneratedFlagExtensions.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` so callers can + * inspect the value source (DEFAULT / REMOTE / etc.): + * ```kotlin + * internal suspend fun ConfigValues.getPromoBannerEnabled(): ConfigValue = + * 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.kt` where `` 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.kt`. + * + * Returns an empty string if [entries] is empty. + */ + public fun generate( + entries: List, + 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, + // so callers can inspect the value source. + val funcName = "get${propertyName.capitalized()}" + "internal suspend fun ConfigValues.$funcName(): ConfigValue<$type> = getValue($objectRef.$propertyName)\n" + } + } +} diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedApplicationPlugin.kt b/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedApplicationPlugin.kt similarity index 100% rename from featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedApplicationPlugin.kt rename to build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedApplicationPlugin.kt diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedExtension.kt b/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedExtension.kt similarity index 100% rename from featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedExtension.kt rename to build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedExtension.kt diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPlugin.kt b/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPlugin.kt similarity index 100% rename from featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPlugin.kt rename to build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPlugin.kt diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FlagContainer.kt b/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FlagContainer.kt similarity index 100% rename from featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FlagContainer.kt rename to build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FlagContainer.kt diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FlagEntryUtils.kt b/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FlagEntryUtils.kt similarity index 56% rename from featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FlagEntryUtils.kt rename to build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FlagEntryUtils.kt index b1f93fe..029ae22 100644 --- a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FlagEntryUtils.kt +++ b/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FlagEntryUtils.kt @@ -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"` @@ -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() } /** diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FlagSpec.kt b/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FlagSpec.kt similarity index 100% rename from featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FlagSpec.kt rename to build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FlagSpec.kt diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateConfigParamTask.kt b/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateConfigParamTask.kt similarity index 63% rename from featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateConfigParamTask.kt rename to build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateConfigParamTask.kt index 624e787..3704eb7 100644 --- a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateConfigParamTask.kt +++ b/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateConfigParamTask.kt @@ -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.kt` — public object with one `ConfigParam` per local flag. + * - `GeneratedRemoteFlags.kt` — public object with one `ConfigParam` per remote flag. + * - `GeneratedFlagExtensions.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: @@ -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 @@ -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 } diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateIosConstValTask.kt b/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateIosConstValTask.kt similarity index 100% rename from featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateIosConstValTask.kt rename to build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateIosConstValTask.kt diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateProguardRulesTask.kt b/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateProguardRulesTask.kt similarity index 100% rename from featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateProguardRulesTask.kt rename to build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateProguardRulesTask.kt diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateXcconfigTask.kt b/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateXcconfigTask.kt similarity index 100% rename from featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateXcconfigTask.kt rename to build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateXcconfigTask.kt diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/IosConstValGenerator.kt b/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/IosConstValGenerator.kt similarity index 100% rename from featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/IosConstValGenerator.kt rename to build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/IosConstValGenerator.kt diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/LocalFlagEntry.kt b/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/LocalFlagEntry.kt similarity index 93% rename from featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/LocalFlagEntry.kt rename to build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/LocalFlagEntry.kt index a4b072c..7ea04ca 100644 --- a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/LocalFlagEntry.kt +++ b/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/LocalFlagEntry.kt @@ -41,7 +41,5 @@ public data class LocalFlagEntry( public companion object { public const val FLAG_TYPE_LOCAL: String = "local" public const val FLAG_TYPE_REMOTE: String = "remote" - internal const val GENERATED_LOCAL_OBJECT = "GeneratedLocalFlags" - internal const val GENERATED_REMOTE_OBJECT = "GeneratedRemoteFlags" } } diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ProguardRulesGenerator.kt b/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ProguardRulesGenerator.kt similarity index 100% rename from featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ProguardRulesGenerator.kt rename to build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ProguardRulesGenerator.kt diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ResolveFlagsTask.kt b/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ResolveFlagsTask.kt similarity index 100% rename from featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ResolveFlagsTask.kt rename to build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ResolveFlagsTask.kt diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ScanResultParser.kt b/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ScanResultParser.kt similarity index 100% rename from featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ScanResultParser.kt rename to build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ScanResultParser.kt diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/XcconfigGenerator.kt b/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/XcconfigGenerator.kt similarity index 100% rename from featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/XcconfigGenerator.kt rename to build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/XcconfigGenerator.kt diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/AggregationContract.kt b/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/AggregationContract.kt similarity index 100% rename from featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/AggregationContract.kt rename to build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/AggregationContract.kt diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GenerateFeaturedRegistryTask.kt b/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GenerateFeaturedRegistryTask.kt similarity index 100% rename from featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GenerateFeaturedRegistryTask.kt rename to build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GenerateFeaturedRegistryTask.kt diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GeneratedFeaturedRegistryGenerator.kt b/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GeneratedFeaturedRegistryGenerator.kt similarity index 96% rename from featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GeneratedFeaturedRegistryGenerator.kt rename to build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GeneratedFeaturedRegistryGenerator.kt index b23888f..554ebe9 100644 --- a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GeneratedFeaturedRegistryGenerator.kt +++ b/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GeneratedFeaturedRegistryGenerator.kt @@ -59,6 +59,9 @@ internal object GeneratedFeaturedRegistryGenerator { add("defaultValue = $defaultLiteral") if (descriptor.description != null) add("description = \"${escapeKotlinString(descriptor.description)}\"") if (descriptor.category != null) add("category = \"${escapeKotlinString(descriptor.category)}\"") + if (descriptor.valueType == ValueType.ENUM) { + add("enumConstants = kotlin.enums.enumEntries<${descriptor.enumTypeFqn}>()") + } } // Kotlin accepts trailing commas in listOf() — always emit one for uniform diffs. appendLine(" ConfigParam<$typeArg>(${args.joinToString(", ")}),") diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifest.kt b/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifest.kt similarity index 100% rename from featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifest.kt rename to build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifest.kt diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestContract.kt b/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestContract.kt similarity index 100% rename from featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestContract.kt rename to build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestContract.kt diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/GenerateFeaturedManifestTask.kt b/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/GenerateFeaturedManifestTask.kt similarity index 100% rename from featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/GenerateFeaturedManifestTask.kt rename to build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/GenerateFeaturedManifestTask.kt diff --git a/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/app/build.gradle.kts b/build-logic/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/app/build.gradle.kts similarity index 100% rename from featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/app/build.gradle.kts rename to build-logic/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/app/build.gradle.kts diff --git a/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/app/src/main/AndroidManifest.xml b/build-logic/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/app/src/main/AndroidManifest.xml similarity index 100% rename from featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/app/src/main/AndroidManifest.xml rename to build-logic/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/app/src/main/AndroidManifest.xml diff --git a/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/build.gradle.kts b/build-logic/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/build.gradle.kts similarity index 100% rename from featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/build.gradle.kts rename to build-logic/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/build.gradle.kts diff --git a/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-checkout/build.gradle.kts b/build-logic/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-checkout/build.gradle.kts similarity index 100% rename from featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-checkout/build.gradle.kts rename to build-logic/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-checkout/build.gradle.kts diff --git a/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-checkout/src/main/AndroidManifest.xml b/build-logic/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-checkout/src/main/AndroidManifest.xml similarity index 100% rename from featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-checkout/src/main/AndroidManifest.xml rename to build-logic/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-checkout/src/main/AndroidManifest.xml diff --git a/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-profile/build.gradle.kts b/build-logic/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-profile/build.gradle.kts similarity index 100% rename from featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-profile/build.gradle.kts rename to build-logic/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-profile/build.gradle.kts diff --git a/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-profile/src/main/AndroidManifest.xml b/build-logic/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-profile/src/main/AndroidManifest.xml similarity index 100% rename from featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-profile/src/main/AndroidManifest.xml rename to build-logic/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-profile/src/main/AndroidManifest.xml diff --git a/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/gradle.properties b/build-logic/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/gradle.properties similarity index 100% rename from featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/gradle.properties rename to build-logic/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/gradle.properties diff --git a/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/settings.gradle.kts b/build-logic/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/settings.gradle.kts similarity index 100% rename from featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/settings.gradle.kts rename to build-logic/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/settings.gradle.kts diff --git a/featured-gradle-plugin/src/test/fixtures/android-project/build.gradle.kts b/build-logic/featured-gradle-plugin/src/test/fixtures/android-project/build.gradle.kts similarity index 100% rename from featured-gradle-plugin/src/test/fixtures/android-project/build.gradle.kts rename to build-logic/featured-gradle-plugin/src/test/fixtures/android-project/build.gradle.kts diff --git a/featured-gradle-plugin/src/test/fixtures/android-project/settings.gradle.kts b/build-logic/featured-gradle-plugin/src/test/fixtures/android-project/settings.gradle.kts similarity index 100% rename from featured-gradle-plugin/src/test/fixtures/android-project/settings.gradle.kts rename to build-logic/featured-gradle-plugin/src/test/fixtures/android-project/settings.gradle.kts diff --git a/featured-gradle-plugin/src/test/fixtures/android-project/src/main/AndroidManifest.xml b/build-logic/featured-gradle-plugin/src/test/fixtures/android-project/src/main/AndroidManifest.xml similarity index 100% rename from featured-gradle-plugin/src/test/fixtures/android-project/src/main/AndroidManifest.xml rename to build-logic/featured-gradle-plugin/src/test/fixtures/android-project/src/main/AndroidManifest.xml diff --git a/featured-gradle-plugin/src/test/fixtures/android-project/src/main/kotlin/dev/androidbroadcast/featured/testapp/CheckoutVariant.kt b/build-logic/featured-gradle-plugin/src/test/fixtures/android-project/src/main/kotlin/dev/androidbroadcast/featured/testapp/CheckoutVariant.kt similarity index 100% rename from featured-gradle-plugin/src/test/fixtures/android-project/src/main/kotlin/dev/androidbroadcast/featured/testapp/CheckoutVariant.kt rename to build-logic/featured-gradle-plugin/src/test/fixtures/android-project/src/main/kotlin/dev/androidbroadcast/featured/testapp/CheckoutVariant.kt diff --git a/featured-gradle-plugin/src/test/fixtures/jvm-empty-featured-project/build.gradle.kts b/build-logic/featured-gradle-plugin/src/test/fixtures/jvm-empty-featured-project/build.gradle.kts similarity index 100% rename from featured-gradle-plugin/src/test/fixtures/jvm-empty-featured-project/build.gradle.kts rename to build-logic/featured-gradle-plugin/src/test/fixtures/jvm-empty-featured-project/build.gradle.kts diff --git a/featured-gradle-plugin/src/test/fixtures/jvm-empty-featured-project/settings.gradle.kts b/build-logic/featured-gradle-plugin/src/test/fixtures/jvm-empty-featured-project/settings.gradle.kts similarity index 100% rename from featured-gradle-plugin/src/test/fixtures/jvm-empty-featured-project/settings.gradle.kts rename to build-logic/featured-gradle-plugin/src/test/fixtures/jvm-empty-featured-project/settings.gradle.kts diff --git a/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/build.gradle.kts b/build-logic/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/build.gradle.kts similarity index 100% rename from featured-gradle-plugin/src/test/fixtures/kmp-publish-project/build.gradle.kts rename to build-logic/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/build.gradle.kts diff --git a/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/gradle.properties b/build-logic/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/gradle.properties similarity index 100% rename from featured-gradle-plugin/src/test/fixtures/kmp-publish-project/gradle.properties rename to build-logic/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/gradle.properties diff --git a/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/module/build.gradle.kts b/build-logic/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/module/build.gradle.kts similarity index 100% rename from featured-gradle-plugin/src/test/fixtures/kmp-publish-project/module/build.gradle.kts rename to build-logic/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/module/build.gradle.kts diff --git a/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/module/src/commonMain/kotlin/.gitkeep b/build-logic/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/module/src/commonMain/kotlin/.gitkeep similarity index 100% rename from featured-gradle-plugin/src/test/fixtures/kmp-publish-project/module/src/commonMain/kotlin/.gitkeep rename to build-logic/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/module/src/commonMain/kotlin/.gitkeep diff --git a/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/settings.gradle.kts b/build-logic/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/settings.gradle.kts similarity index 100% rename from featured-gradle-plugin/src/test/fixtures/kmp-publish-project/settings.gradle.kts rename to build-logic/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/settings.gradle.kts diff --git a/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/app/build.gradle.kts b/build-logic/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/app/build.gradle.kts similarity index 100% rename from featured-gradle-plugin/src/test/fixtures/manifest-publish-project/app/build.gradle.kts rename to build-logic/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/app/build.gradle.kts diff --git a/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/app/src/main/AndroidManifest.xml b/build-logic/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/app/src/main/AndroidManifest.xml similarity index 100% rename from featured-gradle-plugin/src/test/fixtures/manifest-publish-project/app/src/main/AndroidManifest.xml rename to build-logic/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/app/src/main/AndroidManifest.xml diff --git a/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/build.gradle.kts b/build-logic/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/build.gradle.kts similarity index 100% rename from featured-gradle-plugin/src/test/fixtures/manifest-publish-project/build.gradle.kts rename to build-logic/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/build.gradle.kts diff --git a/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/gradle.properties b/build-logic/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/gradle.properties similarity index 100% rename from featured-gradle-plugin/src/test/fixtures/manifest-publish-project/gradle.properties rename to build-logic/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/gradle.properties diff --git a/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/settings.gradle.kts b/build-logic/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/settings.gradle.kts similarity index 100% rename from featured-gradle-plugin/src/test/fixtures/manifest-publish-project/settings.gradle.kts rename to build-logic/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/settings.gradle.kts diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ConfigParamGeneratorTest.kt b/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ConfigParamGeneratorTest.kt similarity index 60% rename from featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ConfigParamGeneratorTest.kt rename to build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ConfigParamGeneratorTest.kt index 1ea6cb5..7ca7cb7 100644 --- a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ConfigParamGeneratorTest.kt +++ b/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ConfigParamGeneratorTest.kt @@ -6,20 +6,22 @@ import kotlin.test.assertEquals import kotlin.test.assertTrue class ConfigParamGeneratorTest { + private val modulePath = ":app" + // ── local flags ─────────────────────────────────────────────────────────── @Test - fun `generates GeneratedLocalFlags object for local boolean flag`() { + fun `generates module-suffixed local flags object for local boolean flag`() { val entries = listOf(localEntry("dark_mode", "false", "Boolean")) - val (local, _) = ConfigParamGenerator.generate(entries) - assertContains(local, "object GeneratedLocalFlags") + val (local, _) = ConfigParamGenerator.generate(entries, modulePath) + assertContains(local, "object GeneratedLocalFlagsApp") assertContains(local, "val darkMode = ConfigParam") } @Test fun `generated local ConfigParam uses named key argument`() { val entries = listOf(localEntry("dark_mode", "false", "Boolean")) - val (local, _) = ConfigParamGenerator.generate(entries) + val (local, _) = ConfigParamGenerator.generate(entries, modulePath) assertContains(local, "key = \"dark_mode\"") assertContains(local, "defaultValue = false") } @@ -27,67 +29,67 @@ class ConfigParamGeneratorTest { @Test fun `generated local ConfigParam includes description when present`() { val entry = localEntry("dark_mode", "false", "Boolean").copy(description = "Enable dark mode") - val (local, _) = ConfigParamGenerator.generate(listOf(entry)) + val (local, _) = ConfigParamGenerator.generate(listOf(entry), modulePath) assertContains(local, "description = \"Enable dark mode\"") } @Test fun `generated local ConfigParam includes category when present`() { val entry = localEntry("dark_mode", "false", "Boolean").copy(category = "UI") - val (local, _) = ConfigParamGenerator.generate(listOf(entry)) + val (local, _) = ConfigParamGenerator.generate(listOf(entry), modulePath) assertContains(local, "category = \"UI\"") } @Test fun `generated local ConfigParam omits null description`() { val entries = listOf(localEntry("dark_mode", "false", "Boolean")) - val (local, _) = ConfigParamGenerator.generate(entries) + val (local, _) = ConfigParamGenerator.generate(entries, modulePath) assertTrue(!local.contains("description ="), "Null description must not appear in output") } @Test - fun `local object is internal`() { + fun `local object is public`() { val entries = listOf(localEntry("dark_mode", "false", "Boolean")) - val (local, _) = ConfigParamGenerator.generate(entries) - assertContains(local, "internal object GeneratedLocalFlags") + val (local, _) = ConfigParamGenerator.generate(entries, modulePath) + assertContains(local, "public object GeneratedLocalFlagsApp") } @Test fun `formats Long default with L suffix`() { val entries = listOf(localEntry("timeout", "5000L", "Long")) - val (local, _) = ConfigParamGenerator.generate(entries) + val (local, _) = ConfigParamGenerator.generate(entries, modulePath) assertContains(local, "defaultValue = 5000L") } @Test fun `formats Float default with f suffix`() { val entries = listOf(localEntry("ratio", "0.5f", "Float")) - val (local, _) = ConfigParamGenerator.generate(entries) + val (local, _) = ConfigParamGenerator.generate(entries, modulePath) assertContains(local, "defaultValue = 0.5f") } @Test fun `passes String default as-is (already quoted)`() { val entries = listOf(localEntry("url", "\"https://x.com\"", "String")) - val (local, _) = ConfigParamGenerator.generate(entries) + val (local, _) = ConfigParamGenerator.generate(entries, modulePath) assertContains(local, "defaultValue = \"https://x.com\"") } // ── remote flags ────────────────────────────────────────────────────────── @Test - fun `generates GeneratedRemoteFlags object for remote flag`() { + fun `generates module-suffixed remote flags object for remote flag`() { val entries = listOf(remoteEntry("promo_banner", "false", "Boolean")) - val (_, remote) = ConfigParamGenerator.generate(entries) - assertContains(remote, "object GeneratedRemoteFlags") + val (_, remote) = ConfigParamGenerator.generate(entries, modulePath) + assertContains(remote, "object GeneratedRemoteFlagsApp") assertContains(remote, "val promoBanner = ConfigParam") } @Test - fun `remote object is internal`() { + fun `remote object is public`() { val entries = listOf(remoteEntry("promo", "false", "Boolean")) - val (_, remote) = ConfigParamGenerator.generate(entries) - assertContains(remote, "internal object GeneratedRemoteFlags") + val (_, remote) = ConfigParamGenerator.generate(entries, modulePath) + assertContains(remote, "public object GeneratedRemoteFlagsApp") } // ── empty cases ─────────────────────────────────────────────────────────── @@ -95,20 +97,20 @@ class ConfigParamGeneratorTest { @Test fun `returns empty string for local when no local flags`() { val entries = listOf(remoteEntry("promo", "false", "Boolean")) - val (local, _) = ConfigParamGenerator.generate(entries) + val (local, _) = ConfigParamGenerator.generate(entries, modulePath) assertTrue(local.isEmpty(), "Expected empty local source when no local flags") } @Test fun `returns empty string for remote when no remote flags`() { val entries = listOf(localEntry("dark_mode", "false", "Boolean")) - val (_, remote) = ConfigParamGenerator.generate(entries) + val (_, remote) = ConfigParamGenerator.generate(entries, modulePath) assertTrue(remote.isEmpty(), "Expected empty remote source when no remote flags") } @Test fun `both empty for empty entries list`() { - val (local, remote) = ConfigParamGenerator.generate(emptyList()) + val (local, remote) = ConfigParamGenerator.generate(emptyList(), modulePath) assertEquals("", local) assertEquals("", remote) } @@ -118,40 +120,82 @@ class ConfigParamGeneratorTest { @Test fun `generated local file imports ConfigParam`() { val entries = listOf(localEntry("flag", "false", "Boolean")) - val (local, _) = ConfigParamGenerator.generate(entries) + val (local, _) = ConfigParamGenerator.generate(entries, modulePath) assertContains(local, "import dev.androidbroadcast.featured.ConfigParam") } @Test fun `generated file has auto-generated comment`() { val entries = listOf(localEntry("flag", "false", "Boolean")) - val (local, _) = ConfigParamGenerator.generate(entries) + val (local, _) = ConfigParamGenerator.generate(entries, modulePath) assertContains(local, "Auto-generated by Featured Gradle Plugin") } + // ── module-derived naming ───────────────────────────────────────────────── + + @Test + fun `different modules produce different object names`() { + val entries = listOf(localEntry("dark_mode", "false", "Boolean")) + val (localA, _) = ConfigParamGenerator.generate(entries, ":feature:checkout") + val (localB, _) = ConfigParamGenerator.generate(entries, ":feature:ui") + assertContains(localA, "object GeneratedLocalFlagsFeatureCheckout") + assertContains(localB, "object GeneratedLocalFlagsFeatureUi") + } + + @Test + fun `hyphenated module segment produces valid object name`() { + val entries = listOf(localEntry("dark_mode", "false", "Boolean")) + val (local, _) = ConfigParamGenerator.generate(entries, ":sample:feature-checkout") + assertContains(local, "object GeneratedLocalFlagsSampleFeatureCheckout") + } + + @Test + fun `localFileName uses module suffix`() { + assertEquals("GeneratedLocalFlagsSampleFeatureCheckout.kt", ConfigParamGenerator.localFileName(":sample:feature-checkout")) + } + + @Test + fun `remoteFileName uses module suffix`() { + assertEquals("GeneratedRemoteFlagsSampleFeaturePromotions.kt", ConfigParamGenerator.remoteFileName(":sample:feature-promotions")) + } + // ── enum flags ──────────────────────────────────────────────────────────── @Test fun `generates enum ConfigParam with fqn type argument`() { val entries = listOf(localEntry("checkout_variant", "LEGACY", "com.example.CheckoutVariant")) - val (local, _) = ConfigParamGenerator.generate(entries) + val (local, _) = ConfigParamGenerator.generate(entries, modulePath) assertContains(local, "ConfigParam") } @Test fun `enum default value uses fqn dot constant syntax`() { val entries = listOf(localEntry("checkout_variant", "LEGACY", "com.example.CheckoutVariant")) - val (local, _) = ConfigParamGenerator.generate(entries) + val (local, _) = ConfigParamGenerator.generate(entries, modulePath) assertContains(local, "defaultValue = com.example.CheckoutVariant.LEGACY") } @Test fun `enum flag is included in local object`() { val entries = listOf(localEntry("checkout_variant", "LEGACY", "com.example.CheckoutVariant")) - val (local, _) = ConfigParamGenerator.generate(entries) + val (local, _) = ConfigParamGenerator.generate(entries, modulePath) assertContains(local, "val checkoutVariant = ConfigParam") } + @Test + fun `enum flag emits enumConstants with kotlin enumEntries call`() { + val entries = listOf(localEntry("checkout_variant", "LEGACY", "com.example.CheckoutVariant")) + val (local, _) = ConfigParamGenerator.generate(entries, modulePath) + assertContains(local, "enumConstants = kotlin.enums.enumEntries()") + } + + @Test + fun `non-enum flag does not emit enumConstants`() { + val entries = listOf(localEntry("dark_mode", "false", "Boolean")) + val (local, _) = ConfigParamGenerator.generate(entries, modulePath) + assertTrue(!local.contains("enumConstants ="), "Non-enum flag must not emit enumConstants") + } + // ── helpers ─────────────────────────────────────────────────────────────── private fun localEntry( @@ -162,7 +206,7 @@ class ConfigParamGeneratorTest { key = key, defaultValue = default, type = type, - moduleName = ":app", + moduleName = modulePath, propertyName = key.toCamelCase(), flagType = LocalFlagEntry.FLAG_TYPE_LOCAL, ) @@ -175,7 +219,7 @@ class ConfigParamGeneratorTest { key = key, defaultValue = default, type = type, - moduleName = ":app", + moduleName = modulePath, propertyName = key.toCamelCase(), flagType = LocalFlagEntry.FLAG_TYPE_REMOTE, ) diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ExtensionFunctionGeneratorTest.kt b/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ExtensionFunctionGeneratorTest.kt similarity index 74% rename from featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ExtensionFunctionGeneratorTest.kt rename to build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ExtensionFunctionGeneratorTest.kt index ad2e938..82f2d2e 100644 --- a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ExtensionFunctionGeneratorTest.kt +++ b/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ExtensionFunctionGeneratorTest.kt @@ -8,26 +8,33 @@ import kotlin.test.assertTrue class ExtensionFunctionGeneratorTest { private val modulePath = ":feature:checkout" - // ── JVM file name ───────────────────────────────────────────────────────── + // ── generated file name ─────────────────────────────────────────────────── @Test - fun `jvmFileName for app module`() { - val name = ExtensionFunctionGenerator.jvmFileName(":app") + fun `fileName for app module`() { + val name = ExtensionFunctionGenerator.fileName(":app") assertContains(name, "App") - assertContains(name, "FlagExtensionsKt") + assertTrue(name.endsWith(".kt"), "File name must end with .kt") } @Test - fun `jvmFileName for nested module`() { - val name = ExtensionFunctionGenerator.jvmFileName(":feature:checkout") + fun `fileName for nested module`() { + val name = ExtensionFunctionGenerator.fileName(":feature:checkout") assertContains(name, "FeatureCheckout") } @Test - fun `jvmFileName for different modules are distinct`() { - val a = ExtensionFunctionGenerator.jvmFileName(":feature:checkout") - val b = ExtensionFunctionGenerator.jvmFileName(":feature:ui") - assertFalse(a == b, "Different modules must produce distinct JVM names") + fun `fileName for hyphenated module segment`() { + val name = ExtensionFunctionGenerator.fileName(":sample:feature-checkout") + assertContains(name, "SampleFeatureCheckout") + assertFalse(name.contains("-"), "File name must not contain hyphens") + } + + @Test + fun `fileName for different modules are distinct`() { + val a = ExtensionFunctionGenerator.fileName(":feature:checkout") + val b = ExtensionFunctionGenerator.fileName(":feature:ui") + assertFalse(a == b, "Different modules must produce distinct file names") } // ── empty input ─────────────────────────────────────────────────────────── @@ -44,21 +51,21 @@ 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, "fun ConfigValues.isDarkModeEnabled(): Boolean") + assertContains(source, "suspend fun ConfigValues.isDarkModeEnabled(): Boolean") } @Test fun `local boolean extension returns raw value`() { val entries = listOf(localEntry("dark_mode", "Boolean")) val source = ExtensionFunctionGenerator.generate(entries, modulePath) - assertContains(source, "getValue(GeneratedLocalFlags.darkMode).value") + assertContains(source, "getValue(GeneratedLocalFlagsFeatureCheckout.darkMode).value") } @Test - fun `local boolean extension is public`() { + fun `local boolean extension is internal suspend`() { val entries = listOf(localEntry("dark_mode", "Boolean")) val source = ExtensionFunctionGenerator.generate(entries, modulePath) - assertContains(source, "public fun ConfigValues.isDarkModeEnabled()") + assertContains(source, "internal suspend fun ConfigValues.isDarkModeEnabled()") } // ── local non-boolean flag ──────────────────────────────────────────────── @@ -67,15 +74,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, "fun ConfigValues.getMaxRetries(): Int") - assertContains(source, "getValue(GeneratedLocalFlags.maxRetries).value") + assertContains(source, "suspend fun ConfigValues.getMaxRetries(): Int") + assertContains(source, "getValue(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, "fun ConfigValues.getApiUrl(): String") + assertContains(source, "suspend fun ConfigValues.getApiUrl(): String") } // ── local enum flag ─────────────────────────────────────────────────────── @@ -84,8 +91,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, "fun ConfigValues.getCheckoutVariant(): com.example.CheckoutVariant") - assertContains(source, "getValue(GeneratedLocalFlags.checkoutVariant).value") + assertContains(source, "suspend fun ConfigValues.getCheckoutVariant(): com.example.CheckoutVariant") + assertContains(source, "getValue(GeneratedLocalFlagsFeatureCheckout.checkoutVariant).value") } @Test @@ -101,8 +108,8 @@ 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, "fun ConfigValues.getPromoBanner(): ConfigValue") - assertContains(source, "getValue(GeneratedRemoteFlags.promoBanner)") + assertContains(source, "suspend fun ConfigValues.getPromoBanner(): ConfigValue") + assertContains(source, "getValue(GeneratedRemoteFlagsFeatureCheckout.promoBanner)") } @Test @@ -110,7 +117,7 @@ class ExtensionFunctionGeneratorTest { val entries = listOf(remoteEntry("promo_banner", "Boolean")) val source = ExtensionFunctionGenerator.generate(entries, modulePath) assertFalse( - source.contains("GeneratedRemoteFlags.promoBanner).value"), + source.contains("GeneratedRemoteFlagsFeatureCheckout.promoBanner).value"), "Remote extensions must return full ConfigValue, not unwrapped value", ) } @@ -118,10 +125,12 @@ class ExtensionFunctionGeneratorTest { // ── file structure ──────────────────────────────────────────────────────── @Test - fun `generated file has JvmName annotation`() { + fun `generated file does not contain JvmName annotation`() { + // @file:JvmName is not supported on Kotlin/Native; class-name uniqueness is + // achieved via the module-derived file name instead. val entries = listOf(localEntry("flag", "Boolean")) val source = ExtensionFunctionGenerator.generate(entries, modulePath) - assertContains(source, "@file:JvmName(\"${ExtensionFunctionGenerator.jvmFileName(modulePath)}\")") + assertFalse(source.contains("@file:JvmName"), "Generated file must not contain @file:JvmName") } @Test diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPluginIntegrationTest.kt b/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPluginIntegrationTest.kt similarity index 100% rename from featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPluginIntegrationTest.kt rename to build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPluginIntegrationTest.kt diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPluginTest.kt b/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPluginTest.kt similarity index 100% rename from featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPluginTest.kt rename to build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPluginTest.kt diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FlagContainerTest.kt b/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FlagContainerTest.kt similarity index 100% rename from featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FlagContainerTest.kt rename to build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FlagContainerTest.kt diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FlagEntryUtilsTest.kt b/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FlagEntryUtilsTest.kt similarity index 65% rename from featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FlagEntryUtilsTest.kt rename to build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FlagEntryUtilsTest.kt index 91f41eb..ff8625f 100644 --- a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FlagEntryUtilsTest.kt +++ b/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FlagEntryUtilsTest.kt @@ -62,4 +62,36 @@ class FlagEntryUtilsTest { fun `modulePathToIdentifier bare colon returns Root`() { assertEquals("Root", ":".modulePathToIdentifier()) } + + // ── modulePathToFileSuffix ──────────────────────────────────────────────── + + @Test + fun `modulePathToFileSuffix for root app module`() { + assertEquals("App", ":app".modulePathToFileSuffix()) + } + + @Test + fun `modulePathToFileSuffix for nested module`() { + assertEquals("FeatureCheckout", ":feature:checkout".modulePathToFileSuffix()) + } + + @Test + fun `modulePathToFileSuffix for hyphenated segment`() { + assertEquals("SampleFeatureCheckout", ":sample:feature-checkout".modulePathToFileSuffix()) + } + + @Test + fun `modulePathToFileSuffix for deeply nested module`() { + assertEquals("FeaturePaymentUi", ":feature:payment:ui".modulePathToFileSuffix()) + } + + @Test + fun `modulePathToFileSuffix empty string returns Root`() { + assertEquals("Root", "".modulePathToFileSuffix()) + } + + @Test + fun `modulePathToFileSuffix bare colon returns Root`() { + assertEquals("Root", ":".modulePathToFileSuffix()) + } } diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/GenerateIosConstValTaskRegistrationTest.kt b/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/GenerateIosConstValTaskRegistrationTest.kt similarity index 100% rename from featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/GenerateIosConstValTaskRegistrationTest.kt rename to build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/GenerateIosConstValTaskRegistrationTest.kt diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/GenerateProguardRulesTaskRegistrationTest.kt b/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/GenerateProguardRulesTaskRegistrationTest.kt similarity index 100% rename from featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/GenerateProguardRulesTaskRegistrationTest.kt rename to build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/GenerateProguardRulesTaskRegistrationTest.kt diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/GenerateXcconfigTaskRegistrationTest.kt b/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/GenerateXcconfigTaskRegistrationTest.kt similarity index 100% rename from featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/GenerateXcconfigTaskRegistrationTest.kt rename to build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/GenerateXcconfigTaskRegistrationTest.kt diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/IosConstValGeneratorTest.kt b/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/IosConstValGeneratorTest.kt similarity index 100% rename from featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/IosConstValGeneratorTest.kt rename to build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/IosConstValGeneratorTest.kt diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/LocalFlagEntryTest.kt b/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/LocalFlagEntryTest.kt similarity index 100% rename from featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/LocalFlagEntryTest.kt rename to build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/LocalFlagEntryTest.kt diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ProguardRulesGeneratorTest.kt b/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ProguardRulesGeneratorTest.kt similarity index 100% rename from featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ProguardRulesGeneratorTest.kt rename to build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ProguardRulesGeneratorTest.kt diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/XcconfigGeneratorTest.kt b/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/XcconfigGeneratorTest.kt similarity index 100% rename from featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/XcconfigGeneratorTest.kt rename to build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/XcconfigGeneratorTest.kt diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationConfigurationTest.kt b/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationConfigurationTest.kt similarity index 100% rename from featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationConfigurationTest.kt rename to build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationConfigurationTest.kt diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationDescriptorIntegrityTest.kt b/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationDescriptorIntegrityTest.kt similarity index 100% rename from featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationDescriptorIntegrityTest.kt rename to build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationDescriptorIntegrityTest.kt diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationDuplicateKeyTest.kt b/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationDuplicateKeyTest.kt similarity index 100% rename from featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationDuplicateKeyTest.kt rename to build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationDuplicateKeyTest.kt diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationIntegrationTest.kt b/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationIntegrationTest.kt similarity index 100% rename from featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationIntegrationTest.kt rename to build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationIntegrationTest.kt diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationParseErrorTest.kt b/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationParseErrorTest.kt similarity index 100% rename from featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationParseErrorTest.kt rename to build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationParseErrorTest.kt diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GenerateFeaturedRegistryTaskRegistrationTest.kt b/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GenerateFeaturedRegistryTaskRegistrationTest.kt similarity index 100% rename from featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GenerateFeaturedRegistryTaskRegistrationTest.kt rename to build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GenerateFeaturedRegistryTaskRegistrationTest.kt diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GeneratedFeaturedRegistryGeneratorTest.kt b/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GeneratedFeaturedRegistryGeneratorTest.kt similarity index 96% rename from featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GeneratedFeaturedRegistryGeneratorTest.kt rename to build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GeneratedFeaturedRegistryGeneratorTest.kt index 5ecaf60..bd73c34 100644 --- a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GeneratedFeaturedRegistryGeneratorTest.kt +++ b/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GeneratedFeaturedRegistryGeneratorTest.kt @@ -146,6 +146,17 @@ class GeneratedFeaturedRegistryGeneratorTest { ) assertContains(source, "ConfigParam") assertContains(source, "defaultValue = com.example.CheckoutVariant.LEGACY") + assertContains(source, "enumConstants = kotlin.enums.enumEntries()") + } + + @Test + fun `BOOLEAN flag does not emit enumConstants`() { + val source = + GeneratedFeaturedRegistryGenerator.generate( + manifests = listOf(manifest(":app", flag(key = "dark_mode", valueType = ValueType.BOOLEAN, defaultValue = "false"))), + packageName = FEATURED_REGISTRY_PACKAGE, + ) + assertFalse(source.contains("enumConstants"), "enumConstants must not appear for non-enum types") } @Test diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedKmpPublicationTest.kt b/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedKmpPublicationTest.kt similarity index 100% rename from featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedKmpPublicationTest.kt rename to build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedKmpPublicationTest.kt diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestConfigurationTest.kt b/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestConfigurationTest.kt similarity index 100% rename from featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestConfigurationTest.kt rename to build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestConfigurationTest.kt diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestEmptyDslTest.kt b/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestEmptyDslTest.kt similarity index 100% rename from featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestEmptyDslTest.kt rename to build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestEmptyDslTest.kt diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestIntegrationTest.kt b/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestIntegrationTest.kt similarity index 100% rename from featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestIntegrationTest.kt rename to build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestIntegrationTest.kt diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestMappingTest.kt b/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestMappingTest.kt similarity index 100% rename from featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestMappingTest.kt rename to build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestMappingTest.kt diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestSerializationTest.kt b/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestSerializationTest.kt similarity index 100% rename from featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestSerializationTest.kt rename to build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestSerializationTest.kt diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/GenerateFeaturedManifestTaskRegistrationTest.kt b/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/GenerateFeaturedManifestTaskRegistrationTest.kt similarity index 100% rename from featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/GenerateFeaturedManifestTaskRegistrationTest.kt rename to build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/GenerateFeaturedManifestTaskRegistrationTest.kt diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/TestFixtureSupport.kt b/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/TestFixtureSupport.kt similarity index 100% rename from featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/TestFixtureSupport.kt rename to build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/TestFixtureSupport.kt diff --git a/build-logic/settings.gradle.kts b/build-logic/settings.gradle.kts new file mode 100644 index 0000000..201ab5a --- /dev/null +++ b/build-logic/settings.gradle.kts @@ -0,0 +1,61 @@ +@file:Suppress("UnstableApiUsage") + +// pluginManagement must be the first block per Gradle's settings-script rules. + +pluginManagement { + repositories { + google { + mavenContent { + includeGroupAndSubgroups("androidx") + includeGroupAndSubgroups("com.android") + includeGroupAndSubgroups("com.google") + } + } + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositories { + google { + mavenContent { + includeGroupAndSubgroups("androidx") + includeGroupAndSubgroups("com.android") + includeGroupAndSubgroups("com.google") + } + } + mavenCentral() + gradlePluginPortal() + } + versionCatalogs { + create("libs") { + from(files("../gradle/libs.versions.toml")) + } + } +} + +// Propagate VERSION_NAME (and any other properties) from the root gradle.properties into +// this included build so that Vanniktech maven-publish resolves the correct version. +// The root gradle.properties is the single source of truth — do not duplicate VERSION_NAME here. +// +// providers.fileContents() registers the file with Gradle's configuration-cache fingerprint, +// unlike a raw FileInputStream read which is invisible to the cache. When root gradle.properties +// changes (e.g. VERSION_NAME bump), the cache entry is invalidated and the new value is picked up. +val parentPropertiesText: Provider = + providers.fileContents(layout.rootDirectory.file("../gradle.properties")).asText + +gradle.beforeProject { + val parentProps = + java.util.Properties().apply { + parentPropertiesText.orNull?.reader()?.use { load(it) } + } + parentProps.forEach { key, value -> + extensions.extraProperties[key.toString()] = value.toString() + } + // Set project.version directly so Vanniktech's providers.gradleProperty fallback also works. + (parentProps.getProperty("VERSION_NAME") ?: "unspecified").let { version = it } +} + +rootProject.name = "build-logic" +include(":featured-gradle-plugin") diff --git a/build.gradle.kts b/build.gradle.kts index 0ace319..f26360a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -14,6 +14,14 @@ plugins { alias(libs.plugins.dokka) } +tasks.register("publishToMavenCentral") { + dependsOn(gradle.includedBuild("build-logic").task(":featured-gradle-plugin:publishToMavenCentral")) +} + +tasks.register("publishToMavenLocal") { + dependsOn(gradle.includedBuild("build-logic").task(":featured-gradle-plugin:publishToMavenLocal")) +} + spotless { val ktlintVersion = libs.versions.ktlint.get() kotlin { diff --git a/core/src/commonMain/kotlin/dev/androidbroadcast/featured/ConfigParam.kt b/core/src/commonMain/kotlin/dev/androidbroadcast/featured/ConfigParam.kt index 6a465af..0b65f54 100644 --- a/core/src/commonMain/kotlin/dev/androidbroadcast/featured/ConfigParam.kt +++ b/core/src/commonMain/kotlin/dev/androidbroadcast/featured/ConfigParam.kt @@ -58,6 +58,11 @@ public class ConfigParam * Useful for tracking parameter lifecycle. */ public val since: String? = null, + /** + * All declared constants of the enum type, or `null` for non-enum params. + * Populated by the code generator via `enumValues().toList()`; not set for hand-written params. + */ + public val enumConstants: List? = null, ) { override fun equals(other: Any?): Boolean { if (this === other) return true @@ -70,7 +75,8 @@ public class ConfigParam valueType == other.valueType && description == other.description && category == other.category && - since == other.since + since == other.since && + enumConstants == other.enumConstants } override fun hashCode(): Int = key.hashCode() @@ -85,6 +91,7 @@ public class ConfigParam appendIfPresent(key = "description", description) appendIfPresent(key = "category", category) appendIfPresent(key = "since", since) + appendIfPresent(key = "enumConstants", enumConstants) append(')') } @@ -131,6 +138,7 @@ public inline fun ConfigParam( description: String? = null, category: String? = null, since: String? = null, + enumConstants: List? = null, ): ConfigParam = ConfigParam( key = key, @@ -139,4 +147,5 @@ public inline fun ConfigParam( description = description, category = category, since = since, + enumConstants = enumConstants, ) diff --git a/core/src/commonTest/kotlin/dev/androidbroadcast/featured/ConfigParamTest.kt b/core/src/commonTest/kotlin/dev/androidbroadcast/featured/ConfigParamTest.kt index ac5db35..0c9484a 100644 --- a/core/src/commonTest/kotlin/dev/androidbroadcast/featured/ConfigParamTest.kt +++ b/core/src/commonTest/kotlin/dev/androidbroadcast/featured/ConfigParamTest.kt @@ -59,6 +59,7 @@ class ConfigParamTest { assertNull(param.description) assertNull(param.category) assertNull(param.since) + assertNull(param.enumConstants) } @Test @@ -99,4 +100,58 @@ class ConfigParamTest { assertTrue(result.contains("since='1.0'")) assertTrue(result.contains("description='desc'")) } + + @Test + fun testEnumConstantsDefaultsToNull() { + val param = ConfigParam(key = "flag", defaultValue = true) + + assertNull(param.enumConstants) + } + + @Test + fun testEqualsReturnsFalseWhenEnumConstantsDiffer() { + val paramWithConstants = + ConfigParam( + key = "flag", + defaultValue = false, + enumConstants = listOf(true, false), + ) + val paramWithoutConstants = + ConfigParam( + key = "flag", + defaultValue = false, + ) + + assertNotEquals(paramWithConstants, paramWithoutConstants) + } + + @Test + fun testEqualsReturnsTrueWhenEnumConstantsAreIdentical() { + val param1 = + ConfigParam( + key = "flag", + defaultValue = false, + enumConstants = listOf(true, false), + ) + val param2 = + ConfigParam( + key = "flag", + defaultValue = false, + enumConstants = listOf(true, false), + ) + + assertEquals(param1, param2) + } + + @Test + fun testToStringIncludesEnumConstantsWhenPresent() { + val param = + ConfigParam( + key = "flag", + defaultValue = false, + enumConstants = listOf(true, false), + ) + + assertTrue(param.toString().contains("enumConstants=")) + } } diff --git a/featured-bom/build.gradle.kts b/featured-bom/build.gradle.kts index d00b7cf..d94ac3d 100644 --- a/featured-bom/build.gradle.kts +++ b/featured-bom/build.gradle.kts @@ -20,7 +20,6 @@ dependencies { api(project(":featured-compose")) api(project(":featured-debug-ui")) api(project(":featured-testing")) - api(project(":featured-gradle-plugin")) api(project(":featured-lint-rules")) api(project(":featured-platform")) diff --git a/featured-debug-ui/src/commonMain/kotlin/dev/androidbroadcast/featured/debugui/FeatureFlagsDebugScreen.kt b/featured-debug-ui/src/commonMain/kotlin/dev/androidbroadcast/featured/debugui/FeatureFlagsDebugScreen.kt index 0e4828b..199dd88 100644 --- a/featured-debug-ui/src/commonMain/kotlin/dev/androidbroadcast/featured/debugui/FeatureFlagsDebugScreen.kt +++ b/featured-debug-ui/src/commonMain/kotlin/dev/androidbroadcast/featured/debugui/FeatureFlagsDebugScreen.kt @@ -14,7 +14,11 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Badge import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField @@ -155,6 +159,15 @@ public fun FeatureFlagsDebugScreen( ) } }, + onEnumSelect = { newValue -> + scope.launch { + @Suppress("UNCHECKED_CAST") + configValues.override( + item.param as ConfigParam, + newValue, + ) + } + }, onResetToDefault = { scope.launch { configValues.resetOverride(item.param) @@ -174,6 +187,7 @@ private fun FlagItemCard( item: DebugFlagItem<*>, onToggleBoolean: (Boolean) -> Unit, onScalarInput: (Any) -> Unit, + onEnumSelect: (Any) -> Unit, onResetToDefault: () -> Unit, modifier: Modifier = Modifier, ) { @@ -225,6 +239,21 @@ private fun FlagItemCard( ) } + @Suppress("UNCHECKED_CAST") + val enumConstants = item.param.enumConstants as List>? + if (enumConstants != null) { + EnumDropdown( + label = item.key, + currentValue = item.currentValue as Enum<*>, + options = enumConstants, + onSelect = onEnumSelect, + modifier = + Modifier + .fillMaxWidth() + .padding(top = 8.dp), + ) + } + if (item.isOverridden) { HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) Row( @@ -339,3 +368,47 @@ private fun SourceBadge(source: ConfigValue.Source) { Text(text = style.label, style = MaterialTheme.typography.labelSmall) } } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +@Suppress("ktlint:standard:function-naming") +private fun EnumDropdown( + label: String, + currentValue: Enum<*>, + options: List>, + onSelect: (Any) -> Unit, + modifier: Modifier = Modifier, +) { + var expanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + modifier = modifier, + ) { + OutlinedTextField( + value = currentValue.name, + onValueChange = {}, + readOnly = true, + label = { Text(label) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = + Modifier + .menuAnchor(type = ExposedDropdownMenuAnchorType.PrimaryNotEditable, enabled = true) + .fillMaxWidth(), + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + options.forEach { option -> + DropdownMenuItem( + text = { Text(option.name) }, + onClick = { + onSelect(option) + expanded = false + }, + ) + } + } + } +} diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ConfigParamGenerator.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ConfigParamGenerator.kt deleted file mode 100644 index f698f90..0000000 --- a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ConfigParamGenerator.kt +++ /dev/null @@ -1,73 +0,0 @@ -package dev.androidbroadcast.featured.gradle - -/** - * Generates `GeneratedLocalFlags.kt` and `GeneratedRemoteFlags.kt` — internal objects - * containing one typed `ConfigParam` property per declared flag. - * - * Generated example for a local Boolean flag `dark_mode`: - * ```kotlin - * internal object GeneratedLocalFlags { - * val darkMode = ConfigParam("dark_mode", false, category = "UI") - * } - * ``` - * - * These objects are `internal` — consumers access flags exclusively 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" - - /** - * Generates the Kotlin source for `GeneratedLocalFlags.kt` and - * `GeneratedRemoteFlags.kt` as a pair. - * - * 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): Pair { - val (local, remote) = entries.partition { it.isLocal } - return generateObject(local, LocalFlagEntry.GENERATED_LOCAL_OBJECT) to - generateObject(remote, LocalFlagEntry.GENERATED_REMOTE_OBJECT) - } - - private fun generateObject( - entries: List, - 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("internal object $objectName {") - entries.forEach { entry -> - appendLine(" 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\"") - } - 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 - } -} diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ExtensionFunctionGenerator.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ExtensionFunctionGenerator.kt deleted file mode 100644 index cadad7a..0000000 --- a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ExtensionFunctionGenerator.kt +++ /dev/null @@ -1,88 +0,0 @@ -package dev.androidbroadcast.featured.gradle - -/** - * Generates `GeneratedFlagExtensions.kt` — public extension functions on `ConfigValues` - * for each declared flag. - * - * **Local Boolean flags** get an `is…Enabled()` extension returning the raw `Boolean`: - * ```kotlin - * fun ConfigValues.isDarkModeEnabled(): Boolean = getValue(GeneratedLocalFlags.darkMode).value - * ``` - * - * **Local non-Boolean flags** get a `get…()` extension returning the raw value type: - * ```kotlin - * fun ConfigValues.getMaxRetries(): Int = getValue(GeneratedLocalFlags.maxRetries).value - * ``` - * - * **Remote flags** get a `get…()` extension returning `ConfigValue` so callers can - * inspect the value source (DEFAULT / REMOTE / etc.): - * ```kotlin - * fun ConfigValues.getPromoBannerEnabled(): ConfigValue = - * getValue(GeneratedRemoteFlags.promoBannerEnabled) - * ``` - * - * The file uses `@file:JvmName(...)` with a module-derived suffix to guarantee unique - * JVM class names when multiple modules apply the plugin — required for accurate - * per-function ProGuard `-assumevalues` rules. - */ -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 `@file:JvmName` value for the given Gradle module path. - * - * Examples: `":app"` → `"FeaturedApp_FlagExtensionsKt"`, - * `":feature:checkout"` → `"FeaturedFeatureCheckout_FlagExtensionsKt"`. - */ - public fun jvmFileName(modulePath: String): String = "Featured${modulePath.modulePathToIdentifier()}_FlagExtensionsKt" - - /** - * Generates the full source text for `GeneratedFlagExtensions.kt`. - * - * Returns an empty string if [entries] is empty. - */ - public fun generate( - entries: List, - modulePath: String, - ): String { - if (entries.isEmpty()) return "" - val jvmName = jvmFileName(modulePath) - val needsConfigValue = entries.any { !it.isLocal } - - return buildString { - appendLine("// Auto-generated by Featured Gradle Plugin — do not edit manually.") - appendLine("@file:JvmName(\"$jvmName\")") - 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.${LocalFlagEntry.GENERATED_LOCAL_OBJECT}") - } - if (remoteEntries.isNotEmpty()) { - appendLine("import $PACKAGE.${LocalFlagEntry.GENERATED_REMOTE_OBJECT}") - } - appendLine() - entries.forEach { entry -> - appendLine(entry.toExtensionFunction()) - } - }.trimEnd() + "\n" - } - - private fun LocalFlagEntry.toExtensionFunction(): String { - val objectRef = if (isLocal) LocalFlagEntry.GENERATED_LOCAL_OBJECT else LocalFlagEntry.GENERATED_REMOTE_OBJECT - return if (isLocal) { - val funcName = extensionFunctionName() - "public fun ConfigValues.$funcName(): $type = getValue($objectRef.$propertyName).value\n" - } else { - // Remote flags always use get… regardless of type — the return is ConfigValue, - // so callers can inspect the value source. - val funcName = "get${propertyName.capitalized()}" - "public fun ConfigValues.$funcName(): ConfigValue<$type> = getValue($objectRef.$propertyName)\n" - } - } -} diff --git a/sample/CLAUDE.md b/sample/CLAUDE.md new file mode 100644 index 0000000..cc554da --- /dev/null +++ b/sample/CLAUDE.md @@ -0,0 +1,29 @@ +# Sample CLAUDE.md + +The sample is intentionally a multi-module demonstration of the Featured plugin family. + +## Module map + +- `: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:android-app` — Activity shell; wires `DataStoreConfigValueProvider` + `FeatureFlagsDebugScreen`. +- `:sample:desktop` — JVM shell; uses `InMemoryConfigValueProvider`. +- `iosApp/` — Xcode project consuming `FeaturedSampleApp.framework` (static, produced by `:sample:shared`). + +## Observe-bridge convention + +Each `:sample:feature-*` module ships `*FlagObservers.kt` with public `ConfigValues` extensions +(e.g. `mainButtonRedFlow()`, `setMainButtonRed()`). UI consumers should call these instead of +referencing `GeneratedLocalFlags*` / `GeneratedRemoteFlags*` directly. + +## Adding a flag + +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`. + +## 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`. diff --git a/sample/android-app/src/main/kotlin/dev/androidbroadcast/featured/sample/MainActivity.kt b/sample/android-app/src/main/kotlin/dev/androidbroadcast/featured/sample/MainActivity.kt index 817588d..66e6728 100644 --- a/sample/android-app/src/main/kotlin/dev/androidbroadcast/featured/sample/MainActivity.kt +++ b/sample/android-app/src/main/kotlin/dev/androidbroadcast/featured/sample/MainActivity.kt @@ -9,15 +9,15 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import dev.androidbroadcast.featured.CheckoutVariant import dev.androidbroadcast.featured.ConfigValues import dev.androidbroadcast.featured.SampleApp -import dev.androidbroadcast.featured.SampleFeatureFlags import dev.androidbroadcast.featured.datastore.DataStoreConfigValueProvider import dev.androidbroadcast.featured.datastore.registerConverter 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.CheckoutVariant class MainActivity : ComponentActivity() { // ConfigValues is held at Activity scope for this sample. @@ -40,7 +40,7 @@ class MainActivity : ComponentActivity() { if (showDebug) { BackHandler { showDebug = false } - FeatureFlagsDebugScreen(configValues = configValues, registry = SampleFeatureFlags.all) + FeatureFlagsDebugScreen(configValues = configValues, registry = GeneratedFeaturedRegistry.all) } else { SampleApp( configValues = configValues, diff --git a/sample/feature-checkout/build.gradle.kts b/sample/feature-checkout/build.gradle.kts new file mode 100644 index 0000000..95a9bcc --- /dev/null +++ b/sample/feature-checkout/build.gradle.kts @@ -0,0 +1,60 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidKmpLibrary) + id("dev.androidbroadcast.featured") +} + +kotlin { + jvmToolchain(21) + + android { + namespace = "dev.androidbroadcast.featured.sample.checkout" + compileSdk = + libs.versions.android.compileSdk + .get() + .toInt() + minSdk = + libs.versions.android.minSdk + .get() + .toInt() + compilerOptions { + jvmTarget.set(JvmTarget.JVM_21) + } + } + + iosX64() + iosArm64() + iosSimulatorArm64() + + jvm() + + sourceSets { + commonMain.dependencies { + api(project(":core")) + api(libs.kotlinx.coroutines.core) + } + } + + sourceSets.commonMain.get().kotlin.srcDir( + tasks.named("generateConfigParam").map { it.outputs.files.singleFile }, + ) +} + +featured { + localFlags { + boolean("new_checkout", default = false) { + description = "Enable the redesigned checkout flow" + category = "checkout" + } + enum( + key = "checkout_variant", + typeFqn = "dev.androidbroadcast.featured.sample.checkout.CheckoutVariant", + default = "LEGACY", + ) { + description = "Controls which checkout flow variant is shown to the user" + category = "checkout" + } + } +} diff --git a/sample/feature-checkout/src/androidMain/AndroidManifest.xml b/sample/feature-checkout/src/androidMain/AndroidManifest.xml new file mode 100644 index 0000000..b2d3ea1 --- /dev/null +++ b/sample/feature-checkout/src/androidMain/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/sample/feature-checkout/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/checkout/CheckoutFlagObservers.kt b/sample/feature-checkout/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/checkout/CheckoutFlagObservers.kt new file mode 100644 index 0000000..5c92ebe --- /dev/null +++ b/sample/feature-checkout/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/checkout/CheckoutFlagObservers.kt @@ -0,0 +1,17 @@ +package dev.androidbroadcast.featured.sample.checkout + +import dev.androidbroadcast.featured.ConfigValues +import dev.androidbroadcast.featured.generated.GeneratedLocalFlagsSampleFeatureCheckout +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +public fun ConfigValues.newCheckoutFlow(): Flow = observe(GeneratedLocalFlagsSampleFeatureCheckout.newCheckout).map { it.value } + +public fun ConfigValues.checkoutVariantFlow(): Flow = + observe(GeneratedLocalFlagsSampleFeatureCheckout.checkoutVariant).map { + it.value + } + +public suspend fun ConfigValues.setNewCheckout(value: Boolean) { + override(GeneratedLocalFlagsSampleFeatureCheckout.newCheckout, value) +} diff --git a/sample/feature-checkout/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/checkout/CheckoutVariant.kt b/sample/feature-checkout/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/checkout/CheckoutVariant.kt new file mode 100644 index 0000000..82fb01e --- /dev/null +++ b/sample/feature-checkout/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/checkout/CheckoutVariant.kt @@ -0,0 +1,7 @@ +package dev.androidbroadcast.featured.sample.checkout + +public enum class CheckoutVariant { + LEGACY, + NEW_SINGLE_PAGE, + NEW_MULTI_STEP, +} diff --git a/sample/feature-promotions/build.gradle.kts b/sample/feature-promotions/build.gradle.kts new file mode 100644 index 0000000..fc23e29 --- /dev/null +++ b/sample/feature-promotions/build.gradle.kts @@ -0,0 +1,52 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidKmpLibrary) + id("dev.androidbroadcast.featured") +} + +kotlin { + jvmToolchain(21) + + android { + namespace = "dev.androidbroadcast.featured.sample.promotions" + compileSdk = + libs.versions.android.compileSdk + .get() + .toInt() + minSdk = + libs.versions.android.minSdk + .get() + .toInt() + compilerOptions { + jvmTarget.set(JvmTarget.JVM_21) + } + } + + iosX64() + iosArm64() + iosSimulatorArm64() + + jvm() + + sourceSets { + commonMain.dependencies { + api(project(":core")) + api(libs.kotlinx.coroutines.core) + } + } + + sourceSets.commonMain.get().kotlin.srcDir( + tasks.named("generateConfigParam").map { it.outputs.files.singleFile }, + ) +} + +featured { + remoteFlags { + boolean("promo_banner_enabled", default = false) { + description = "Show the promotional banner on the main screen" + category = "promotions" + } + } +} diff --git a/sample/feature-promotions/src/androidMain/AndroidManifest.xml b/sample/feature-promotions/src/androidMain/AndroidManifest.xml new file mode 100644 index 0000000..b2d3ea1 --- /dev/null +++ b/sample/feature-promotions/src/androidMain/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/sample/feature-promotions/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/promotions/PromotionsFlagObservers.kt b/sample/feature-promotions/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/promotions/PromotionsFlagObservers.kt new file mode 100644 index 0000000..093c677 --- /dev/null +++ b/sample/feature-promotions/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/promotions/PromotionsFlagObservers.kt @@ -0,0 +1,15 @@ +package dev.androidbroadcast.featured.sample.promotions + +import dev.androidbroadcast.featured.ConfigValues +import dev.androidbroadcast.featured.generated.GeneratedRemoteFlagsSampleFeaturePromotions +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +public fun ConfigValues.promoBannerEnabledFlow(): Flow = + observe(GeneratedRemoteFlagsSampleFeaturePromotions.promoBannerEnabled).map { + it.value + } + +public suspend fun ConfigValues.setPromoBannerEnabled(value: Boolean) { + override(GeneratedRemoteFlagsSampleFeaturePromotions.promoBannerEnabled, value) +} diff --git a/sample/feature-ui/build.gradle.kts b/sample/feature-ui/build.gradle.kts new file mode 100644 index 0000000..1bbbe9f --- /dev/null +++ b/sample/feature-ui/build.gradle.kts @@ -0,0 +1,56 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidKmpLibrary) + id("dev.androidbroadcast.featured") +} + +kotlin { + jvmToolchain(21) + + android { + namespace = "dev.androidbroadcast.featured.sample.ui" + compileSdk = + libs.versions.android.compileSdk + .get() + .toInt() + minSdk = + libs.versions.android.minSdk + .get() + .toInt() + compilerOptions { + jvmTarget.set(JvmTarget.JVM_21) + } + } + + iosX64() + iosArm64() + iosSimulatorArm64() + + jvm() + + sourceSets { + commonMain.dependencies { + api(project(":core")) + api(libs.kotlinx.coroutines.core) + } + } + + sourceSets.commonMain.get().kotlin.srcDir( + tasks.named("generateConfigParam").map { it.outputs.files.singleFile }, + ) +} + +featured { + localFlags { + boolean("main_button_red", default = true) { + description = "Tint the main button red (otherwise blue)" + category = "ui" + } + boolean("new_feature_section_enabled", default = true) { + description = "Show the new-feature section on the main screen" + category = "ui" + } + } +} diff --git a/sample/feature-ui/src/androidMain/AndroidManifest.xml b/sample/feature-ui/src/androidMain/AndroidManifest.xml new file mode 100644 index 0000000..b2d3ea1 --- /dev/null +++ b/sample/feature-ui/src/androidMain/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/sample/feature-ui/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/ui/UiFlagObservers.kt b/sample/feature-ui/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/ui/UiFlagObservers.kt new file mode 100644 index 0000000..e2a389d --- /dev/null +++ b/sample/feature-ui/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/ui/UiFlagObservers.kt @@ -0,0 +1,19 @@ +package dev.androidbroadcast.featured.sample.ui + +import dev.androidbroadcast.featured.ConfigValues +import dev.androidbroadcast.featured.generated.GeneratedLocalFlagsSampleFeatureUi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +public fun ConfigValues.mainButtonRedFlow(): Flow = observe(GeneratedLocalFlagsSampleFeatureUi.mainButtonRed).map { it.value } + +public suspend fun ConfigValues.setMainButtonRed(value: Boolean) { + override(GeneratedLocalFlagsSampleFeatureUi.mainButtonRed, value) +} + +public fun ConfigValues.newFeatureSectionEnabledFlow(): Flow = + observe(GeneratedLocalFlagsSampleFeatureUi.newFeatureSectionEnabled).map { it.value } + +public suspend fun ConfigValues.setNewFeatureSectionEnabled(value: Boolean) { + override(GeneratedLocalFlagsSampleFeatureUi.newFeatureSectionEnabled, value) +} diff --git a/sample/shared/build.gradle.kts b/sample/shared/build.gradle.kts index d2867b2..4e8f030 100644 --- a/sample/shared/build.gradle.kts +++ b/sample/shared/build.gradle.kts @@ -6,6 +6,7 @@ plugins { alias(libs.plugins.composeMultiplatform) alias(libs.plugins.composeCompiler) alias(libs.plugins.skie) + id("dev.androidbroadcast.featured.application") } kotlin { @@ -34,6 +35,9 @@ kotlin { iosTarget.binaries.framework { baseName = "FeaturedSampleApp" isStatic = true + export(project(":sample:feature-checkout")) + export(project(":sample:feature-promotions")) + export(project(":sample:feature-ui")) } } @@ -54,6 +58,22 @@ kotlin { // the public signatures of SampleApp / SampleViewModel — must be api to compile // downstream consumers like :sample:desktop. Pre-existing leak from #182. api(project(":core")) + + // CheckoutVariant appears in StateFlow in SampleViewModel public API; + // observe-bridge extensions are called from :sample:android-app / :sample:desktop. + api(project(":sample:feature-checkout")) + api(project(":sample:feature-promotions")) + api(project(":sample:feature-ui")) } } + + sourceSets.commonMain.get().kotlin.srcDir( + tasks.named("generateFeaturedRegistry").map { it.outputs.files.singleFile.parentFile }, + ) +} + +dependencies { + featuredAggregation(project(":sample:feature-checkout")) + featuredAggregation(project(":sample:feature-promotions")) + featuredAggregation(project(":sample:feature-ui")) } diff --git a/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/FeaturedSample.kt b/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/FeaturedSample.kt index d54e52d..e9cb530 100644 --- a/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/FeaturedSample.kt +++ b/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/FeaturedSample.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import dev.androidbroadcast.featured.sample.checkout.CheckoutVariant /** * Main sample screen demonstrating `@LocalFlag` and `@RemoteFlag` usage end-to-end. diff --git a/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleFeatureFlags.kt b/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleFeatureFlags.kt deleted file mode 100644 index 757a3bd..0000000 --- a/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleFeatureFlags.kt +++ /dev/null @@ -1,94 +0,0 @@ -package dev.androidbroadcast.featured - -/** - * Checkout flow variant used to demonstrate multivariate (enum) feature flags. - */ -public enum class CheckoutVariant { - /** The original multi-screen checkout flow. */ - LEGACY, - - /** New single-page checkout (A/B experiment arm A). */ - NEW_SINGLE_PAGE, - - /** New multi-step checkout with progress indicator (A/B experiment arm B). */ - NEW_MULTI_STEP, -} - -/** - * Feature flags for the sample app. - * - * In a real consumer project, Boolean/Int/String flags would be declared in - * `build.gradle.kts` using the Featured Gradle DSL, and the plugin would generate - * typed `ConfigParam` objects and `ConfigValues` extension functions automatically: - * - * ```kotlin - * // build.gradle.kts - * featured { - * localFlags { - * boolean("main_button_red", default = true) { category = "ui" } - * boolean("new_feature_section_enabled", default = true) { category = "ui" } - * } - * remoteFlags { - * boolean("promo_banner_enabled", default = false) { - * description = "Show promotional banner" - * } - * } - * } - * ``` - * - * Enum-typed flags (like [checkoutVariant]) are declared manually as `ConfigParam` - * until enum support is added to the DSL. - * - * The sample module is part of the library's own build and cannot apply the plugin - * to itself, so all flags are declared manually here for demonstration purposes. - * [SampleFeatureFlags.all] is the single source of truth for the Debug UI registry. - */ -public object SampleFeatureFlags { - public val mainButtonRed: ConfigParam = - ConfigParam( - key = "main_button_red", - defaultValue = true, - description = "Enable red color for the main button", - category = "ui", - ) - - public val newFeatureSectionEnabled: ConfigParam = - ConfigParam( - key = "new_feature_section_enabled", - defaultValue = true, - description = "Show the new feature section in the main screen", - category = "ui", - ) - - public val newCheckout: ConfigParam = - ConfigParam( - key = "new_checkout", - defaultValue = false, - description = "Enable the redesigned checkout flow", - ) - - public val promoBannerEnabled: ConfigParam = - ConfigParam( - key = "promo_banner_enabled", - defaultValue = false, - description = "Show a promotional banner on the main screen (remote-controlled)", - category = "promotions", - ) - - public val checkoutVariant: ConfigParam = - ConfigParam( - key = "checkout_variant", - defaultValue = CheckoutVariant.LEGACY, - description = "Controls which checkout flow variant is shown to the user", - category = "checkout", - ) - - public val all: List> = - listOf( - mainButtonRed, - newFeatureSectionEnabled, - newCheckout, - promoBannerEnabled, - checkoutVariant, - ) -} diff --git a/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleViewModel.kt b/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleViewModel.kt index 420da49..5b41d1d 100644 --- a/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleViewModel.kt +++ b/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleViewModel.kt @@ -2,6 +2,16 @@ package dev.androidbroadcast.featured import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import dev.androidbroadcast.featured.sample.checkout.CheckoutVariant +import dev.androidbroadcast.featured.sample.checkout.checkoutVariantFlow +import dev.androidbroadcast.featured.sample.checkout.newCheckoutFlow +import dev.androidbroadcast.featured.sample.checkout.setNewCheckout +import dev.androidbroadcast.featured.sample.promotions.promoBannerEnabledFlow +import dev.androidbroadcast.featured.sample.promotions.setPromoBannerEnabled +import dev.androidbroadcast.featured.sample.ui.mainButtonRedFlow +import dev.androidbroadcast.featured.sample.ui.newFeatureSectionEnabledFlow +import dev.androidbroadcast.featured.sample.ui.setMainButtonRed +import dev.androidbroadcast.featured.sample.ui.setNewFeatureSectionEnabled import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map @@ -12,7 +22,10 @@ public class SampleViewModel( private val configValues: ConfigValues, ) : ViewModel() { public val flagActive: StateFlow = - configValues.asStateFlow(SampleFeatureFlags.mainButtonRed, viewModelScope) + configValues + .mainButtonRedFlow() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), true) + // matches default declared in :sample:feature-ui build.gradle.kts public val mainButtonColor: StateFlow = flagActive @@ -25,31 +38,44 @@ public class SampleViewModel( ) public fun setMainButtonColorFlag(value: Boolean) { - viewModelScope.launch { - configValues.override(SampleFeatureFlags.mainButtonRed, value) - } + viewModelScope.launch { configValues.setMainButtonRed(value) } } - /** - * Controls visibility of the "New Feature" section. - * Demonstrates the [ConfigParam.isEnabled] guard pattern for entry-point gating. - */ public val newFeatureSectionEnabled: StateFlow = - configValues.asStateFlow(SampleFeatureFlags.newFeatureSectionEnabled, viewModelScope) + configValues + .newFeatureSectionEnabledFlow() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), true) + // matches default declared in :sample:feature-ui build.gradle.kts - /** - * Whether the promotional banner should be shown. - * In production this would be driven by Firebase Remote Config. - */ public val promoBannerEnabled: StateFlow = - configValues.asStateFlow(SampleFeatureFlags.promoBannerEnabled, viewModelScope) + configValues + .promoBannerEnabledFlow() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), false) + // matches default declared in :sample:feature-promotions build.gradle.kts + + public val newCheckout: StateFlow = + configValues + .newCheckoutFlow() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), false) + // matches default declared in :sample:feature-checkout build.gradle.kts - /** - * The active checkout variant, driven remotely. - * Demonstrates multivariate enum flags resolved from a remote provider. - */ public val checkoutVariant: StateFlow = - configValues.asStateFlow(SampleFeatureFlags.checkoutVariant, viewModelScope) + configValues + .checkoutVariantFlow() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), CheckoutVariant.LEGACY) + // matches default declared in :sample:feature-checkout build.gradle.kts + + public fun setNewFeatureSectionEnabled(value: Boolean) { + viewModelScope.launch { configValues.setNewFeatureSectionEnabled(value) } + } + + public fun setPromoBannerEnabled(value: Boolean) { + viewModelScope.launch { configValues.setPromoBannerEnabled(value) } + } + + public fun setNewCheckout(value: Boolean) { + viewModelScope.launch { configValues.setNewCheckout(value) } + } public sealed interface MainButtonColor { public data object Red : MainButtonColor diff --git a/settings.gradle.kts b/settings.gradle.kts index 1b45cb1..0ab3b91 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -2,6 +2,7 @@ rootProject.name = "Featured" enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") pluginManagement { + includeBuild("build-logic") @Suppress("UnstableApiUsage") repositories { @@ -36,8 +37,10 @@ plugins { id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" } -include(":featured-gradle-plugin") include(":sample:shared") +include(":sample:feature-checkout") +include(":sample:feature-promotions") +include(":sample:feature-ui") include(":sample:android-app") include(":sample:desktop") include(":core") From b2b1220687956a0004e2756a7dd67271e606111e Mon Sep 17 00:00:00 2001 From: Kirill Rozov Date: Wed, 20 May 2026 12:06:41 +0300 Subject: [PATCH 11/15] Restore R8 per-function DCE: sync getValueCached + ProGuard rule fix (#201) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add sync getValueCached read path on ConfigValues ConfigValues now owns an in-memory snapshot (Map> held in kotlin.concurrent.AtomicReference, copy-on-write) and exposes getValueCached(...) — a non-suspend reader that returns the cached ConfigValue or a DEFAULT-sourced fallback before any warm-up write. Reads are safe on any thread including the Android main thread. override(), fetch(), and suspend getValue() all write-through into the snapshot so subsequent sync reads reflect the latest value with the Source the provider reported. resetOverride() clears the local override and dispatches a background coroutine (Dispatchers.Default) that re- resolves the param through the provider priority chain, refreshing the snapshot to whatever remote / default value is current. ConfigValues now owns a SupervisorJob-backed CoroutineScope and implements AutoCloseable so the scope can be torn down deterministically. isEnabled extension is no longer suspend — it delegates to getValueCached so generated is*Enabled() codegen can return to a non-suspend shape (preparing the path for restoring R8 per-function DCE in later phases). Callers that previously awaited isEnabled lose the suspend boundary; Featured does not keep API compatibility. initialize() snapshot warm-up still depends on providers implementing the upcoming SnapshotConfigValueProvider — Phase 2 wires that. Co-Authored-By: Claude Opus 4.7 * Generate non-suspend is*Enabled extensions calling getValueCached ExtensionFunctionGenerator stops emitting suspend on the per-flag extensions and switches the underlying call from suspend getValue to the new non-suspend getValueCached (added in the previous commit). The generated isEnabled() / get() helpers can now be called from any context — including @Composable bodies — without dragging a coroutine scope along. The JVM method signature returns to plain `boolean isEnabled(ConfigValues)` (no Continuation parameter), so the ProGuard -assumevalues rule shape that ProguardRulesGenerator already emits matches the real method again and R8 can resume per-function DCE on dead-flag branches. The shrinker-tests subsystem and the ProGuard rule format itself need no changes — Phase 4 just removes the "no-op" caveats from the relevant KDoc. Generator unit tests pin the new non-suspend / getValueCached shape; end-to-end sample assemble (Android / Desktop / iOS sim) stays green. Co-Authored-By: Claude Opus 4.7 * Point ProGuard assumevalues rules at the real generated class name ProguardRulesGenerator derived the target Kotlin file class name via ExtensionFunctionGenerator.jvmFileName, which reproduced the old @file:JvmName pattern that PR D removed. The real compiled class is named after the source file (e.g. GeneratedFlagExtensionsSampleFeatureUiKt), so R8 silently no-op'd the -assumevalues directive — per-function dead- code elimination did not actually happen in consumer builds. Module paths with hyphens additionally produced JVM-illegal identifiers in the emitted rules, which R8 also ignored without warning. Switch the rule target to the file-name-derived class (drop jvmFileName, reuse ExtensionFunctionGenerator.fileName so the sanitization stays in one place) and update SyntheticBytecodeFactory + the R8 elimination tests to match. The shrinker-tests suite now exercises R8 against bytecode whose JVM class name matches what the rules target — the pipeline is consistent again and the existing R8 DCE assertions are finally exercising the production shape. Co-Authored-By: Claude Opus 4.7 * Document sync ConfigValues read path Records the [Unreleased] CHANGELOG entries for the sync ConfigValues work: new getValueCached reader, non-suspend isEnabled extension, generated codegen losing suspend, AutoCloseable on ConfigValues, and the R8 DCE ProGuard rule fix. The sample CLAUDE.md gains a one- paragraph pointer noting that non-reactive call sites should use getValueCached or the codegen extensions, while reactive UI keeps the observe-bridge pattern that already exists in the multi-module sample. Co-Authored-By: Claude Opus 4.7 * Re-resolve in resetOverride synchronously; drop AutoCloseable Phase A code review flagged that the backgroundScope.launch in resetOverride could clobber a concurrent override(p, X) write: the background re-resolve writes the just-resolved REMOTE value after the caller's LOCAL=X write lands, and snapshot/provider diverge until the next getValue. resetOverride is already suspend, so do the re-resolve inline — the caller pays the same cost and the ordering becomes deterministic. The explicit writeSnapshot call stays because getValue intentionally does not write DEFAULT into the snapshot; without the explicit write a previously overridden slot would stay stale when both providers return null. The synchronous re-resolve makes the internal CoroutineScope unused, so ConfigValues no longer implements AutoCloseable. The CHANGELOG entry is swapped accordingly. observe()'s KDoc gets a more accurate description of write-through behaviour, and the concurrent-write test is renamed to reflect what runTest actually exercises (single-thread coroutine interleaving, not OS-level parallelism). Co-Authored-By: Claude Opus 4.7 --------- Co-authored-by: Claude Opus 4.7 --- CHANGELOG.md | 7 +- .../gradle/ExtensionFunctionGenerator.kt | 31 +-- .../featured/gradle/ProguardRulesGenerator.kt | 8 +- .../gradle/ExtensionFunctionGeneratorTest.kt | 26 ++- .../gradle/FeaturedPluginIntegrationTest.kt | 12 +- .../gradle/ProguardRulesGeneratorTest.kt | 2 +- .../androidbroadcast/featured/ConfigValues.kt | 123 ++++++++++- .../featured/ConfigValuesExtensions.kt | 15 +- .../featured/ConfigValuesCachedTest.kt | 209 ++++++++++++++++++ .../featured/ConfigValuesExtensionsTest.kt | 4 +- .../bytecode/SyntheticBytecodeFactory.kt | 14 +- .../r8/R8BooleanFlagEliminationTest.kt | 4 +- .../shrinker/r8/R8IntFlagEliminationTest.kt | 4 +- sample/CLAUDE.md | 4 + 14 files changed, 398 insertions(+), 65 deletions(-) create mode 100644 core/src/commonTest/kotlin/dev/androidbroadcast/featured/ConfigValuesCachedTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 78fb03f..e980d24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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>, 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): ConfigValue` — non-suspend synchronous reader. Returns the last-written `ConfigValue` 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` — 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> }` 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. @@ -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` specializations. ## [1.0.0-Beta1] - 2026-05-17 diff --git a/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ExtensionFunctionGenerator.kt b/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ExtensionFunctionGenerator.kt index c9e89f1..de07b12 100644 --- a/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ExtensionFunctionGenerator.kt +++ b/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ExtensionFunctionGenerator.kt @@ -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` so callers can * inspect the value source (DEFAULT / REMOTE / etc.): * ```kotlin - * internal suspend fun ConfigValues.getPromoBannerEnabled(): ConfigValue = - * getValue(GeneratedRemoteFlags.promoBannerEnabled) + * internal fun ConfigValues.getPromoBannerEnabled(): ConfigValue = + * 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 @@ -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.kt`. * @@ -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, // 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" } } } diff --git a/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ProguardRulesGenerator.kt b/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ProguardRulesGenerator.kt index d1961a3..0cad793 100644 --- a/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ProguardRulesGenerator.kt +++ b/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ProguardRulesGenerator.kt @@ -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; * } * ``` @@ -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. */ @@ -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.") diff --git a/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ExtensionFunctionGeneratorTest.kt b/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ExtensionFunctionGeneratorTest.kt index 82f2d2e..5e28f7f 100644 --- a/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ExtensionFunctionGeneratorTest.kt +++ b/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ExtensionFunctionGeneratorTest.kt @@ -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 ──────────────────────────────────────────────── @@ -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 ─────────────────────────────────────────────────────── @@ -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 @@ -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") - assertContains(source, "getValue(GeneratedRemoteFlagsFeatureCheckout.promoBanner)") + assertContains(source, "fun ConfigValues.getPromoBanner(): ConfigValue") + assertContains(source, "getValueCached(GeneratedRemoteFlagsFeatureCheckout.promoBanner)") + assertFalse(source.contains("suspend "), "Must not emit suspend modifier anywhere") } @Test diff --git a/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPluginIntegrationTest.kt b/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPluginIntegrationTest.kt index ede0348..b894b48 100644 --- a/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPluginIntegrationTest.kt +++ b/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPluginIntegrationTest.kt @@ -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). @@ -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" } diff --git a/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ProguardRulesGeneratorTest.kt b/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ProguardRulesGeneratorTest.kt index 55f7fab..78b24a1 100644 --- a/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ProguardRulesGeneratorTest.kt +++ b/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ProguardRulesGeneratorTest.kt @@ -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 ────────────────────────────────────────────────── diff --git a/core/src/commonMain/kotlin/dev/androidbroadcast/featured/ConfigValues.kt b/core/src/commonMain/kotlin/dev/androidbroadcast/featured/ConfigValues.kt index dd755c8..1e778c7 100644 --- a/core/src/commonMain/kotlin/dev/androidbroadcast/featured/ConfigValues.kt +++ b/core/src/commonMain/kotlin/dev/androidbroadcast/featured/ConfigValues.kt @@ -1,4 +1,5 @@ @file:Suppress("unused") +@file:OptIn(kotlin.concurrent.atomics.ExperimentalAtomicApi::class) package dev.androidbroadcast.featured @@ -9,6 +10,8 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge +import kotlin.concurrent.atomics.AtomicReference +import kotlin.concurrent.atomics.update /** * Central access point for reading, overriding, and observing configuration values. @@ -28,6 +31,18 @@ import kotlinx.coroutines.flow.merge * [fetch] is **not** guarded — the caller explicitly triggers a network operation and is * responsible for handling any exceptions it throws. * + * ### Sync read path + * + * [getValueCached] reads from an in-memory snapshot without any provider I/O. The snapshot is + * populated lazily by [getValue], [override], and [fetch]. Before any of these have been called + * for a given parameter, [getValueCached] returns a [ConfigValue] with + * [ConfigValue.Source.DEFAULT] wrapping [ConfigParam.defaultValue] — matching Firebase + * Remote Config's "activate then read sync" contract. + * + * Note (Phase-1 limitation): values written directly to a [LocalConfigValueProvider] without + * going through [ConfigValues.override] bypass the snapshot and will not be visible to + * [getValueCached] until the next [getValue] or [observe] emission for that parameter. + * * ```kotlin * val configValues = ConfigValues( * localProvider = InMemoryConfigValueProvider(), @@ -38,7 +53,10 @@ import kotlinx.coroutines.flow.merge * // Load cached remote values at app start (no network call) * configValues.initialize() * - * // One-shot read — never throws due to provider failure + * // Sync read — safe from any thread; returns DEFAULT until cache is warm + * val enabled: Boolean = configValues.getValueCached(DarkModeParam).value + * + * // One-shot async read — never throws due to provider failure; also warms the cache * val value: ConfigValue = configValues.getValue(DarkModeParam) * * // Reactive observation — flow does not terminate on provider error @@ -66,6 +84,58 @@ public class ConfigValues( private val fetchSignal = MutableSharedFlow(extraBufferCapacity = 1) + /** + * In-memory snapshot of the most recently resolved [ConfigValue] per parameter key. + * + * Key: [ConfigParam.key]. Value: [ConfigValue] as resolved at last write time. + * + * Two [ConfigParam] instances sharing the same [ConfigParam.key] map to the same snapshot + * slot; the last write wins. Within a single code-generated module keys are unique; + * cross-module key collisions are theoretically possible and documented as last-write-wins. + * + * Written via copy-on-write using [AtomicReference.update]; reads via [AtomicReference.load] + * are always consistent snapshots. Thread-safe on all KMP targets. + */ + private val snapshot = AtomicReference>>(emptyMap()) + + /** Writes [configValue] into the snapshot under [param]'s key (copy-on-write). */ + private fun writeSnapshot( + param: ConfigParam, + configValue: ConfigValue, + ) { + snapshot.update { current -> current + (param.key to configValue) } + } + + /** + * Returns the currently cached [ConfigValue] for [param] without performing any I/O. + * + * Returns a [ConfigValue] with [ConfigValue.Source.DEFAULT] wrapping [ConfigParam.defaultValue] + * until the snapshot is populated by one of: + * - [getValue] — performs an async resolution and writes through to the snapshot, + * - [fetch] — pulls fresh values from the remote provider (bulk warm-up in Phase 2), + * - [override] — sets a local override and writes through to the snapshot. + * + * **Duplicate-key semantics:** two [ConfigParam] instances with the same [ConfigParam.key] + * share one snapshot slot; the last write wins. Codegen guarantees uniqueness within a + * module; cross-module collisions are possible and intentionally handled this way. + * + * Thread-safe. Safe to call from any thread, including the Android main thread. + * + * @param param The configuration parameter to read. + * @return The cached [ConfigValue], or a [ConfigValue.Source.DEFAULT] wrapper if the cache + * has not been populated for this parameter yet. + */ + public fun getValueCached(param: ConfigParam): ConfigValue { + val cached = snapshot.load()[param.key] + @Suppress("UNCHECKED_CAST") // safe: written by writeSnapshot which enforces T at write time + return if (cached != null) { + cached as ConfigValue + } else { + @Suppress("HardcodedFlagValue") // intentional: cold-read before cache is warm returns DEFAULT + ConfigValue(param.defaultValue, ConfigValue.Source.DEFAULT) + } + } + /** * Returns the current value for [param], applying provider priority. * @@ -74,6 +144,9 @@ public class ConfigValues( * Provider exceptions are caught and forwarded to [onProviderError]; this function * never throws due to provider failure. * + * The resolved value is written through to the sync snapshot so subsequent calls to + * [getValueCached] for the same parameter reflect this result without further I/O. + * * @param param The configuration parameter to read. * @return The resolved [ConfigValue], never `null`. */ @@ -83,17 +156,25 @@ public class ConfigValues( onProviderError(error) null } - if (localValue != null) return localValue + if (localValue != null) { + writeSnapshot(param, localValue) + return localValue + } val remoteValue = remoteProvider?.runCatching { get(param) }?.getOrElse { error -> onProviderError(error) null } - if (remoteValue != null) return remoteValue + if (remoteValue != null) { + writeSnapshot(param, remoteValue) + return remoteValue + } @Suppress("HardcodedFlagValue") // intentional: this IS the provider fallback path - return ConfigValue(param.defaultValue, ConfigValue.Source.DEFAULT) + val defaultValue = ConfigValue(param.defaultValue, ConfigValue.Source.DEFAULT) + // Do not write DEFAULT into the snapshot: a later override / fetch should still win. + return defaultValue } /** @@ -101,6 +182,9 @@ public class ConfigValues( * This method is used to set a user-specific value that will take precedence over * any remote value for the specified parameter. * + * After the provider write succeeds, the new value is written through to the sync + * snapshot so [getValueCached] reflects the override immediately. + * * Usually used for testing purposes or to allow users to customize. * * @param param The configuration parameter to override. @@ -110,16 +194,30 @@ public class ConfigValues( value: T, ) { localProvider?.set(param, value) + if (localProvider != null) { + writeSnapshot(param, ConfigValue(value, ConfigValue.Source.LOCAL)) + } } /** * Clears the local override for the given parameter, so subsequent reads fall back * to remote or default values. * + * After the local override is cleared, the effective value is re-resolved synchronously + * through the full provider priority chain and written through to the sync snapshot. + * [getValueCached] reflects the new value as soon as this function returns. + * * @param param The configuration parameter whose local override should be cleared. */ public suspend fun resetOverride(param: ConfigParam) { localProvider?.resetOverride(param) + // Re-resolve via the full priority chain and write through so the snapshot converges + // to remote/default rather than staying at the stale LOCAL value. + // Explicit writeSnapshot is required because getValue intentionally does not write + // DEFAULT into the snapshot (see getValue implementation). Without this write, a + // previously overridden slot would remain stale even when both providers return null. + val resolved = getValue(param) + writeSnapshot(param, resolved) } /** @@ -127,6 +225,10 @@ public class ConfigValues( * * After this call, every [getValue] call falls back to the remote provider or * [ConfigParam.defaultValue]. Has no effect when no local provider is configured. + * + * Note: the sync snapshot is **not** cleared here. Individual param slots are updated + * lazily when [getValue] or [resetOverride] is called for each param. This is consistent + * with the fact that [ConfigValues] does not maintain a registry of all known params. */ public suspend fun clearOverrides() { localProvider?.clear() @@ -143,6 +245,10 @@ public class ConfigValues( * or when no remote provider is configured. * * Does **not** perform a network fetch; use [fetch] for that. + * + * **Phase-2 note:** bulk snapshot warm-up via `SnapshotConfigValueProvider` is not yet wired + * here. The sync snapshot remains empty after [initialize] until individual params are + * resolved via [getValue] or [observe]. */ public suspend fun initialize() { (remoteProvider as? InitializableConfigValueProvider)?.initialize() @@ -152,6 +258,10 @@ public class ConfigValues( * Fetches the latest configuration values from the remote provider and activates them. * Any active [observe] flows will re-emit the updated value for the observed parameter. * Has no effect when no remote provider is configured. + * + * **Phase-2 note:** bulk snapshot warm-up after fetch (via `SnapshotConfigValueProvider`) + * is not yet implemented. The snapshot is updated lazily per-param as [observe] or + * [getValue] callers process the [fetchSignal]. */ public suspend fun fetch() { if (remoteProvider == null) return @@ -166,6 +276,11 @@ public class ConfigValues( * - the value changes via the local provider, **or** * - [fetch] completes and the remote provider returns a new value. * + * Note: local-provider direct emissions (i.e. direct calls to the provider's own `set` + * method, bypassing [ConfigValues.override]) reach observers reactively but do **not** write + * through to the snapshot. Use [ConfigValues.override] instead of the provider's `set` if + * [getValueCached] must reflect the write. + * * @param param The configuration parameter to observe. * @return A [Flow] of [ConfigValue] for the specified parameter. */ diff --git a/core/src/commonMain/kotlin/dev/androidbroadcast/featured/ConfigValuesExtensions.kt b/core/src/commonMain/kotlin/dev/androidbroadcast/featured/ConfigValuesExtensions.kt index 035cee6..aa57d94 100644 --- a/core/src/commonMain/kotlin/dev/androidbroadcast/featured/ConfigValuesExtensions.kt +++ b/core/src/commonMain/kotlin/dev/androidbroadcast/featured/ConfigValuesExtensions.kt @@ -58,8 +58,14 @@ public fun ConfigValues.asStateFlow( /** * Returns `true` if the Boolean configuration parameter [param] is currently enabled. * - * This is a convenience wrapper around [ConfigValues.getValue] for [Boolean] parameters, - * eliminating the need to unwrap the [ConfigValue] envelope at every call site. + * This is a **synchronous**, non-suspend read from the in-memory snapshot. It returns + * [ConfigParam.defaultValue] before the snapshot is warmed by [ConfigValues.getValue], + * [ConfigValues.fetch], or [ConfigValues.override] — matching Firebase Remote Config's + * "activate-then-read" contract. + * + * This replaces the previous `suspend` variant (breaking change). Callers that used + * `runBlocking { isEnabled(p) }` or collected inside a coroutine scope can now call it + * directly from any context. * * ```kotlin * if (configValues.isEnabled(MyFeatureParam)) { @@ -68,9 +74,10 @@ public fun ConfigValues.asStateFlow( * ``` * * @param param The Boolean configuration parameter to read. - * @return The current value of [param], or [ConfigParam.defaultValue] when no provider returns one. + * @return The cached value of [param], or [ConfigParam.defaultValue] when the snapshot has + * not been populated for this parameter yet. */ -public suspend fun ConfigValues.isEnabled(param: ConfigParam): Boolean = getValue(param).value +public fun ConfigValues.isEnabled(param: ConfigParam): Boolean = getValueCached(param).value /** * Returns a [Flow] that emits the current enabled-state for [param] and updates on every change. diff --git a/core/src/commonTest/kotlin/dev/androidbroadcast/featured/ConfigValuesCachedTest.kt b/core/src/commonTest/kotlin/dev/androidbroadcast/featured/ConfigValuesCachedTest.kt new file mode 100644 index 0000000..4000dc6 --- /dev/null +++ b/core/src/commonTest/kotlin/dev/androidbroadcast/featured/ConfigValuesCachedTest.kt @@ -0,0 +1,209 @@ +package dev.androidbroadcast.featured + +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +/** + * Unit tests for [ConfigValues.getValueCached] — the synchronous read path — and its + * write-through wiring from [ConfigValues.getValue], [ConfigValues.override], + * [ConfigValues.resetOverride], and [ConfigValues.isEnabled]. + */ +class ConfigValuesCachedTest { + private val param = ConfigParam(key = "flag", defaultValue = false) + + // --------------------------------------------------------------------------- + // Cold-read before any warm-up + // --------------------------------------------------------------------------- + + @Test + fun `getValueCached returns DEFAULT before any warm-up`() { + val configValues = ConfigValues(localProvider = InMemoryConfigValueProvider()) + + val result = configValues.getValueCached(param) + + assertEquals(false, result.value) + assertEquals(ConfigValue.Source.DEFAULT, result.source) + } + + // --------------------------------------------------------------------------- + // Write-through from override + // --------------------------------------------------------------------------- + + @Test + fun `getValueCached returns LOCAL after override`() = + runTest { + val configValues = ConfigValues(localProvider = InMemoryConfigValueProvider()) + configValues.override(param, true) + + val result = configValues.getValueCached(param) + + assertEquals(true, result.value) + assertEquals(ConfigValue.Source.LOCAL, result.source) + } + + // --------------------------------------------------------------------------- + // Write-through from suspend getValue + // --------------------------------------------------------------------------- + + @Test + fun `getValueCached after suspend getValue reflects the resolved value`() = + runTest { + val remote = + object : RemoteConfigValueProvider { + override suspend fun fetch(activate: Boolean) = Unit + + override suspend fun get(param: ConfigParam): ConfigValue? { + @Suppress("UNCHECKED_CAST") + return if (param.key == "flag") ConfigValue(true as T, ConfigValue.Source.REMOTE) else null + } + } + val configValues = ConfigValues(remoteProvider = remote) + + // Snapshot is empty before getValue + assertEquals(ConfigValue.Source.DEFAULT, configValues.getValueCached(param).source) + + configValues.getValue(param) + + // Snapshot is now warm + val cached = configValues.getValueCached(param) + assertEquals(true, cached.value) + assertEquals(ConfigValue.Source.REMOTE, cached.source) + } + + // --------------------------------------------------------------------------- + // Write-through from remote fetch path (via observe / getValue after fetch) + // --------------------------------------------------------------------------- + + @Test + fun `getValueCached returns REMOTE after fetch and getValue`() = + runTest { + val remote = + object : RemoteConfigValueProvider { + override suspend fun fetch(activate: Boolean) = Unit + + override suspend fun get(param: ConfigParam): ConfigValue? { + @Suppress("UNCHECKED_CAST") + return ConfigValue(true as T, ConfigValue.Source.REMOTE) + } + } + val configValues = ConfigValues(remoteProvider = remote) + + configValues.fetch() + configValues.getValue(param) // warms the snapshot + + val cached = configValues.getValueCached(param) + assertEquals(true, cached.value) + assertEquals(ConfigValue.Source.REMOTE, cached.source) + } + + // --------------------------------------------------------------------------- + // resetOverride re-resolution + // --------------------------------------------------------------------------- + + @Test + fun `getValueCached after resetOverride re-resolves through priority chain`() = + runTest { + val remote = + object : RemoteConfigValueProvider { + override suspend fun fetch(activate: Boolean) = Unit + + override suspend fun get(param: ConfigParam): ConfigValue? { + @Suppress("UNCHECKED_CAST") + return if (param.key == "flag") ConfigValue(true as T, ConfigValue.Source.REMOTE) else null + } + } + val local = InMemoryConfigValueProvider() + val configValues = ConfigValues(localProvider = local, remoteProvider = remote) + + // Set a local override + configValues.override(param, false) + assertEquals(false, configValues.getValueCached(param).value) + assertEquals(ConfigValue.Source.LOCAL, configValues.getValueCached(param).source) + + // Reset the override; the local provider no longer holds a value. + configValues.resetOverride(param) + + // Re-resolve via the suspend path — this is the observable contract: after + // resetOverride, getValue re-applies the priority chain (remote wins here). + val resolved = configValues.getValue(param) + assertEquals(true, resolved.value) + assertEquals(ConfigValue.Source.REMOTE, resolved.source) + + // The write-through from getValue means getValueCached now reflects REMOTE too. + val cached = configValues.getValueCached(param) + assertEquals(true, cached.value) + assertNotEquals(ConfigValue.Source.LOCAL, cached.source) + } + + // --------------------------------------------------------------------------- + // Snapshot consistency: concurrent coroutine writes (single-threaded dispatcher) + // --------------------------------------------------------------------------- + + @Test + fun `getValueCached does not corrupt snapshot under interleaved coroutine writes`() = + runTest { + val configValues = ConfigValues(localProvider = InMemoryConfigValueProvider()) + + // Launch 100 interleaved override writes with alternating values. + // runTest runs on a single-threaded dispatcher, so this exercises interleaving + // of coroutine suspension points rather than true OS-thread parallelism. + repeat(100) { i -> + launch { + configValues.override(param, i % 2 == 0) + } + } + + // Reads during interleaved writes must not throw and must return a valid Boolean. + repeat(100) { + configValues.getValueCached(param) + } + + testScheduler.runCurrent() + + // After all coroutines complete, the snapshot must hold one of the written values + // (not an illegal state). Source must be LOCAL because override() was called. + assertEquals(ConfigValue.Source.LOCAL, configValues.getValueCached(param).source) + } + + // --------------------------------------------------------------------------- + // Duplicate-key last-write-wins semantic + // --------------------------------------------------------------------------- + + @Test + fun `last-write-wins - two ConfigParams with same key share snapshot slot`() = + runTest { + val configValues = ConfigValues(localProvider = InMemoryConfigValueProvider()) + val param1 = ConfigParam(key = "shared_key", defaultValue = false) + val param2 = ConfigParam(key = "shared_key", defaultValue = false) + + configValues.override(param1, false) + configValues.override(param2, true) // wins because it is the last write + + // Both params read from the same snapshot slot + assertEquals(true, configValues.getValueCached(param1).value) + assertEquals(true, configValues.getValueCached(param2).value) + } + + // --------------------------------------------------------------------------- + // Sync isEnabled + // --------------------------------------------------------------------------- + + @Test + fun `sync isEnabled returns false before warm-up for default=false param`() { + val configValues = ConfigValues(localProvider = InMemoryConfigValueProvider()) + + assertEquals(false, configValues.isEnabled(param)) + } + + @Test + fun `sync isEnabled returns true after override with true value`() = + runTest { + val configValues = ConfigValues(localProvider = InMemoryConfigValueProvider()) + configValues.override(param, true) + + assertEquals(true, configValues.isEnabled(param)) + } +} diff --git a/core/src/commonTest/kotlin/dev/androidbroadcast/featured/ConfigValuesExtensionsTest.kt b/core/src/commonTest/kotlin/dev/androidbroadcast/featured/ConfigValuesExtensionsTest.kt index 9d02287..c38c2f7 100644 --- a/core/src/commonTest/kotlin/dev/androidbroadcast/featured/ConfigValuesExtensionsTest.kt +++ b/core/src/commonTest/kotlin/dev/androidbroadcast/featured/ConfigValuesExtensionsTest.kt @@ -70,7 +70,9 @@ class ConfigValuesExtensionsTest { runTest { val provider = InMemoryConfigValueProvider() val configValues = ConfigValues(localProvider = provider) - provider.set(darkModeParam, true) + // Use configValues.override so the snapshot is warmed; isEnabled is now non-suspend + // and reads from the snapshot only. + configValues.override(darkModeParam, true) assertEquals(true, configValues.isEnabled(darkModeParam)) } diff --git a/featured-shrinker-tests/src/test/kotlin/dev/androidbroadcast/featured/shrinker/bytecode/SyntheticBytecodeFactory.kt b/featured-shrinker-tests/src/test/kotlin/dev/androidbroadcast/featured/shrinker/bytecode/SyntheticBytecodeFactory.kt index 71a5674..7c4d1d4 100644 --- a/featured-shrinker-tests/src/test/kotlin/dev/androidbroadcast/featured/shrinker/bytecode/SyntheticBytecodeFactory.kt +++ b/featured-shrinker-tests/src/test/kotlin/dev/androidbroadcast/featured/shrinker/bytecode/SyntheticBytecodeFactory.kt @@ -34,10 +34,11 @@ internal const val IF_BRANCH_CODE_INTERNAL = "IfBranchCode" internal const val ELSE_BRANCH_CODE_INTERNAL = "ElseBranchCode" internal const val BIFURCATED_CALLER_INTERNAL = "BifurcatedCaller" -// Derived from ExtensionFunctionGenerator.jvmFileName(":test"): -// ":test".removePrefix(":") = "test" → capitalize → "Test" → "FeaturedTest_FlagExtensionsKt" +// Derived from ExtensionFunctionGenerator.fileName(":test"): +// modulePathToFileSuffix(":test") = "Test" → "GeneratedFlagExtensionsTest.kt" +// → JVM class (Kotlin file-to-class convention): "GeneratedFlagExtensionsTestKt" internal const val BOOL_EXTENSIONS_INTERNAL = - "dev/androidbroadcast/featured/generated/FeaturedTest_FlagExtensionsKt" + "dev/androidbroadcast/featured/generated/GeneratedFlagExtensionsTestKt" internal const val IS_DARK_MODE_ENABLED = "isDarkModeEnabled" @@ -46,10 +47,11 @@ internal const val INT_CONFIG_VALUES_INTERNAL = "dev/androidbroadcast/featured/I internal const val POSITIVE_COUNT_CODE_INTERNAL = "PositiveCountCode" internal const val INT_CALLER_INTERNAL = "IntCaller" -// Derived from ExtensionFunctionGenerator.jvmFileName(":int-test"): -// ":int-test".removePrefix(":") = "int-test" → capitalize first char → "Int-test" → "FeaturedInt-test_FlagExtensionsKt" +// Derived from ExtensionFunctionGenerator.fileName(":int-test"): +// modulePathToFileSuffix(":int-test") splits on "-" → "Int" + "Test" = "IntTest" +// → "GeneratedFlagExtensionsIntTest.kt" → JVM class: "GeneratedFlagExtensionsIntTestKt" internal const val INT_EXTENSIONS_INTERNAL = - "dev/androidbroadcast/featured/generated/FeaturedInt-test_FlagExtensionsKt" + "dev/androidbroadcast/featured/generated/GeneratedFlagExtensionsIntTestKt" internal const val GET_MAX_RETRIES = "getMaxRetries" diff --git a/featured-shrinker-tests/src/test/kotlin/dev/androidbroadcast/featured/shrinker/r8/R8BooleanFlagEliminationTest.kt b/featured-shrinker-tests/src/test/kotlin/dev/androidbroadcast/featured/shrinker/r8/R8BooleanFlagEliminationTest.kt index fda8aba..3219a4b 100644 --- a/featured-shrinker-tests/src/test/kotlin/dev/androidbroadcast/featured/shrinker/r8/R8BooleanFlagEliminationTest.kt +++ b/featured-shrinker-tests/src/test/kotlin/dev/androidbroadcast/featured/shrinker/r8/R8BooleanFlagEliminationTest.kt @@ -26,7 +26,7 @@ import kotlin.test.Test * class ConfigValues { boolean enabled; ConfigValues(boolean) } * * // Mirrors ExtensionFunctionGenerator output for module ":test" - * class FeaturedTest_FlagExtensionsKt { + * class GeneratedFlagExtensionsTestKt { * static boolean isDarkModeEnabled(ConfigValues cv) { return cv.enabled; } * } * @@ -41,7 +41,7 @@ import kotlin.test.Test * class BifurcatedCaller { * static void execute(boolean enabled) { * ConfigValues cv = new ConfigValues(enabled); - * if (FeaturedTest_FlagExtensionsKt.isDarkModeEnabled(cv)) { + * if (GeneratedFlagExtensionsTestKt.isDarkModeEnabled(cv)) { * new IfBranchCode().doWork(); * } else { * new ElseBranchCode().doWork(); diff --git a/featured-shrinker-tests/src/test/kotlin/dev/androidbroadcast/featured/shrinker/r8/R8IntFlagEliminationTest.kt b/featured-shrinker-tests/src/test/kotlin/dev/androidbroadcast/featured/shrinker/r8/R8IntFlagEliminationTest.kt index c786092..751a901 100644 --- a/featured-shrinker-tests/src/test/kotlin/dev/androidbroadcast/featured/shrinker/r8/R8IntFlagEliminationTest.kt +++ b/featured-shrinker-tests/src/test/kotlin/dev/androidbroadcast/featured/shrinker/r8/R8IntFlagEliminationTest.kt @@ -18,7 +18,7 @@ import kotlin.test.Test * ```java * class IntConfigValues { int count; IntConfigValues(int) } * - * class FeaturedIntTest_FlagExtensionsKt { + * class GeneratedFlagExtensionsIntTestKt { * static int getMaxRetries(IntConfigValues cv) { return cv.count; } * } * @@ -27,7 +27,7 @@ import kotlin.test.Test * class IntCaller { * static void execute(int count) { * IntConfigValues cv = new IntConfigValues(count); - * if (FeaturedIntTest_FlagExtensionsKt.getMaxRetries(cv) > 0) { + * if (GeneratedFlagExtensionsIntTestKt.getMaxRetries(cv) > 0) { * new PositiveCountCode().doWork(); * } * } diff --git a/sample/CLAUDE.md b/sample/CLAUDE.md index cc554da..bd78b67 100644 --- a/sample/CLAUDE.md +++ b/sample/CLAUDE.md @@ -18,6 +18,10 @@ Each `:sample:feature-*` module ships `*FlagObservers.kt` with public `ConfigVal (e.g. `mainButtonRedFlow()`, `setMainButtonRed()`). UI consumers should call these instead of referencing `GeneratedLocalFlags*` / `GeneratedRemoteFlags*` directly. +For non-reactive reads (logging, eager-conditional code paths) use `configValues.getValueCached(param)` +directly — the codegen-emitted `is*Enabled()` / `get*()` extensions are non-suspend and call this +under the hood. + ## Adding a flag 1. Edit the feature module's `build.gradle.kts` — add a declaration inside `featured { localFlags { ... } }` or `featured { remoteFlags { ... } }`. From 42fa81cff503ce84e59439b87965bc06c9e0bc68 Mon Sep 17 00:00:00 2001 From: Kirill Rozov Date: Wed, 20 May 2026 16:23:26 +0300 Subject: [PATCH 12/15] Per-module ConfigValues + internal generated objects (#202) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Generate flag objects as internal Flip the visibility of generated GeneratedLocalFlags* and GeneratedRemoteFlags* objects from public to internal. A module's flag declarations are an implementation detail; cross-module introspection flows exclusively through GeneratedFeaturedRegistry.all. Update ConfigParamGenerator KDoc, ExtensionFunctionGenerator KDoc, and ConfigParamGeneratorTest to match the new contract. * Split SampleViewModel into per-feature VMs and wire per-module ConfigValues Each :sample:feature-* module now owns its ViewModel and ConfigValues: - CheckoutFlagsViewModel in :sample:feature-checkout - PromotionsFlagsViewModel in :sample:feature-promotions - UiFlagsViewModel + MainButtonColor in :sample:feature-ui SampleApp/FeaturedSample accept three per-feature VMs as parameters instead of a single shared ConfigValues. Platform shells (Activity, desktop main, iOS UIViewController) construct four ConfigValues from one shared local provider and pass them to the appropriate VMs. Co-Authored-By: Claude Sonnet 4.6 * Document multi-module ConfigValues wiring pattern Co-Authored-By: Claude Sonnet 4.6 * Fix stale SampleViewModel references in sample/CLAUDE.md * Address finalize Round 1 Phase A findings - UiFlagsViewModel.mainButtonColor: fix initial value Blue → Red to match flagActive's true default (no cold-start flicker) - Desktop and iOS shells: add debugConfigValues for pattern consistency per plan §10.3/§10.4 * Address finalize Round 1 Phase B findings - Revert Phase A finding 3: dead Desktop/iOS debugConfigValues vars; doc-only note that those shells omit the debug aggregator since they have no debug-UI entry - FeaturedSample: internal (only used by SampleApp wrapper in same module) - MainButton: private (only used in same file) - MainViewController.kt: drop RedundantVisibilityModifier suppression (public required by explicit-API mode) - sample/CLAUDE.md: clarify debug aggregator is Android-only * Address finalize Round 1 Phase C findings - UiFlagsViewModel: remove flagActive public API (dual-exposure of Boolean + MainButtonColor); make setter accept MainButtonColor instead of Boolean for symmetric vocabulary - MainButtonColor: drop unused companion object Default (contradicted ViewModel's initialValue) - FeaturedSample: derive activate locally from buttonColor, call setMainButtonColor with domain type - ConfigParamGeneratorTest: add symmetric remote-public-val assertion (parity with local) * Address review: comment + README accuracy fixes - README: snippet uses defaultLocalProvider(applicationContext) instead of incorrect DataStoreConfigValueProvider(context, ...) (real signature takes DataStore) - sample/shared/build.gradle.kts: rewrite stale api(:core) rationale — :core is referenced from iosMain (MainViewController) and via per-feature VM constructors, not SampleApp's signature - MainViewController.kt: drop misleading "iOS equivalent of Application scope" wording; UIViewController lifetime is screen/session, not app-wide --------- Co-authored-by: Claude Sonnet 4.6 --- CHANGELOG.md | 3 +- README.md | 29 ++++++ .../featured/gradle/ConfigParamGenerator.kt | 18 ++-- .../gradle/ExtensionFunctionGenerator.kt | 2 +- .../gradle/ConfigParamGeneratorTest.kt | 22 ++++- .../androidbroadcast/featured/ConfigValues.kt | 10 +++ gradle/libs.versions.toml | 1 + sample/CLAUDE.md | 10 ++- sample/android-app/build.gradle.kts | 2 + .../featured/sample/MainActivity.kt | 40 +++++++-- .../androidbroadcast/featured/Main.Desktop.kt | 29 +++++- sample/feature-checkout/build.gradle.kts | 2 + .../sample/checkout/CheckoutFlagsViewModel.kt | 29 ++++++ sample/feature-promotions/build.gradle.kts | 2 + .../promotions/PromotionsFlagsViewModel.kt | 23 +++++ sample/feature-ui/build.gradle.kts | 2 + .../featured/sample/ui/MainButtonColor.kt | 7 ++ .../featured/sample/ui/UiFlagsViewModel.kt | 40 +++++++++ sample/shared/build.gradle.kts | 12 +-- .../featured/FeaturedSample.kt | 46 ++++++---- .../androidbroadcast/featured/SampleApp.kt | 20 ++++- .../featured/SampleViewModel.kt | 89 ------------------- .../featured/MainViewController.kt | 32 +++++-- 23 files changed, 321 insertions(+), 149 deletions(-) create mode 100644 sample/feature-checkout/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/checkout/CheckoutFlagsViewModel.kt create mode 100644 sample/feature-promotions/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/promotions/PromotionsFlagsViewModel.kt create mode 100644 sample/feature-ui/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/ui/MainButtonColor.kt create mode 100644 sample/feature-ui/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/ui/UiFlagsViewModel.kt delete mode 100644 sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleViewModel.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index e980d24..da4260b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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>, 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 diff --git a/README.md b/README.md index 30c3574..7147969 100644 --- a/README.md +++ b/README.md @@ -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): diff --git a/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ConfigParamGenerator.kt b/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ConfigParamGenerator.kt index 9853328..01e80a9 100644 --- a/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ConfigParamGenerator.kt +++ b/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ConfigParamGenerator.kt @@ -1,13 +1,13 @@ package dev.androidbroadcast.featured.gradle /** - * Generates `GeneratedLocalFlags.kt` and `GeneratedRemoteFlags.kt` — public + * Generates `GeneratedLocalFlags.kt` and `GeneratedRemoteFlags.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("dark_mode", false, category = "UI") + * internal object GeneratedLocalFlagsSampleFeatureCheckout { + * val darkMode = ConfigParam("dark_mode", false, category = "UI") * } * ``` * @@ -15,10 +15,10 @@ package dev.androidbroadcast.featured.gradle * 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" @@ -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("}") } diff --git a/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ExtensionFunctionGenerator.kt b/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ExtensionFunctionGenerator.kt index de07b12..8eb23d7 100644 --- a/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ExtensionFunctionGenerator.kt +++ b/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ExtensionFunctionGenerator.kt @@ -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 diff --git a/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ConfigParamGeneratorTest.kt b/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ConfigParamGeneratorTest.kt index 7ca7cb7..399e258 100644 --- a/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ConfigParamGeneratorTest.kt +++ b/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ConfigParamGeneratorTest.kt @@ -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 @@ -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 ─────────────────────────────────────────────────────────── diff --git a/core/src/commonMain/kotlin/dev/androidbroadcast/featured/ConfigValues.kt b/core/src/commonMain/kotlin/dev/androidbroadcast/featured/ConfigValues.kt index 1e778c7..d0dfa68 100644 --- a/core/src/commonMain/kotlin/dev/androidbroadcast/featured/ConfigValues.kt +++ b/core/src/commonMain/kotlin/dev/androidbroadcast/featured/ConfigValues.kt @@ -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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c3d1ca6..94e2a25 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/sample/CLAUDE.md b/sample/CLAUDE.md index bd78b67..204e552 100644 --- a/sample/CLAUDE.md +++ b/sample/CLAUDE.md @@ -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`). @@ -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. diff --git a/sample/android-app/build.gradle.kts b/sample/android-app/build.gradle.kts index 3f4c463..d005a0a 100644 --- a/sample/android-app/build.gradle.kts +++ b/sample/android-app/build.gradle.kts @@ -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) } diff --git a/sample/android-app/src/main/kotlin/dev/androidbroadcast/featured/sample/MainActivity.kt b/sample/android-app/src/main/kotlin/dev/androidbroadcast/featured/sample/MainActivity.kt index 66e6728..a5e5f0d 100644 --- a/sample/android-app/src/main/kotlin/dev/androidbroadcast/featured/sample/MainActivity.kt +++ b/sample/android-app/src/main/kotlin/dev/androidbroadcast/featured/sample/MainActivity.kt @@ -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 @@ -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()) - 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 }, ) } diff --git a/sample/desktop/src/jvmMain/kotlin/dev/androidbroadcast/featured/Main.Desktop.kt b/sample/desktop/src/jvmMain/kotlin/dev/androidbroadcast/featured/Main.Desktop.kt index 0f1ad0d..7795739 100644 --- a/sample/desktop/src/jvmMain/kotlin/dev/androidbroadcast/featured/Main.Desktop.kt +++ b/sample/desktop/src/jvmMain/kotlin/dev/androidbroadcast/featured/Main.Desktop.kt @@ -4,17 +4,38 @@ package dev.androidbroadcast.featured import androidx.compose.ui.window.Window import androidx.compose.ui.window.application +import dev.androidbroadcast.featured.sample.checkout.CheckoutFlagsViewModel +import dev.androidbroadcast.featured.sample.promotions.PromotionsFlagsViewModel +import dev.androidbroadcast.featured.sample.ui.UiFlagsViewModel -// ConfigValues is constructed once at the application entry point and passed -// explicitly — the recommended pattern for multi-module apps using DI. +// Each feature module gets its own ConfigValues backed by the same in-memory provider. +// Per-module ConfigValues is the pattern Featured is designed around: flags are scoped +// to the module that declared them. fun main() { - val configValues = ConfigValues(localProvider = InMemoryConfigValueProvider()) + val sharedLocalProvider = InMemoryConfigValueProvider() + + val checkoutConfigValues = ConfigValues(localProvider = sharedLocalProvider) + val promotionsConfigValues = ConfigValues(localProvider = sharedLocalProvider) + val uiConfigValues = ConfigValues(localProvider = sharedLocalProvider) + // No debug aggregator on Desktop — the Compose Desktop shell does not wire a debug-UI + // entry. The Android shell builds a fourth `ConfigValues` for the debug screen. + + // VMs are constructed once here — the desktop application has a single-window lifetime + // with no configuration changes, so there is no need for a ViewModelStore. + val checkoutViewModel = CheckoutFlagsViewModel(checkoutConfigValues) + val promotionsViewModel = PromotionsFlagsViewModel(promotionsConfigValues) + val uiViewModel = UiFlagsViewModel(uiConfigValues) + application { Window( onCloseRequest = ::exitApplication, title = "Featured", ) { - SampleApp(configValues = configValues) + SampleApp( + uiViewModel = uiViewModel, + promotionsViewModel = promotionsViewModel, + checkoutViewModel = checkoutViewModel, + ) } } } diff --git a/sample/feature-checkout/build.gradle.kts b/sample/feature-checkout/build.gradle.kts index 95a9bcc..4d4373b 100644 --- a/sample/feature-checkout/build.gradle.kts +++ b/sample/feature-checkout/build.gradle.kts @@ -34,6 +34,8 @@ kotlin { commonMain.dependencies { api(project(":core")) api(libs.kotlinx.coroutines.core) + // CheckoutFlagsViewModel is in this module's public API surface. + api(libs.androidx.lifecycle.viewmodel) } } diff --git a/sample/feature-checkout/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/checkout/CheckoutFlagsViewModel.kt b/sample/feature-checkout/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/checkout/CheckoutFlagsViewModel.kt new file mode 100644 index 0000000..73f827a --- /dev/null +++ b/sample/feature-checkout/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/checkout/CheckoutFlagsViewModel.kt @@ -0,0 +1,29 @@ +package dev.androidbroadcast.featured.sample.checkout + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.androidbroadcast.featured.ConfigValues +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +public class CheckoutFlagsViewModel( + private val configValues: ConfigValues, +) : ViewModel() { + public val newCheckout: StateFlow = + configValues + .newCheckoutFlow() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), false) + // matches default declared in :sample:feature-checkout build.gradle.kts + + public val checkoutVariant: StateFlow = + configValues + .checkoutVariantFlow() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), CheckoutVariant.LEGACY) + // matches default declared in :sample:feature-checkout build.gradle.kts + + public fun setNewCheckout(value: Boolean) { + viewModelScope.launch { configValues.setNewCheckout(value) } + } +} diff --git a/sample/feature-promotions/build.gradle.kts b/sample/feature-promotions/build.gradle.kts index fc23e29..79824bd 100644 --- a/sample/feature-promotions/build.gradle.kts +++ b/sample/feature-promotions/build.gradle.kts @@ -34,6 +34,8 @@ kotlin { commonMain.dependencies { api(project(":core")) api(libs.kotlinx.coroutines.core) + // PromotionsFlagsViewModel is in this module's public API surface. + api(libs.androidx.lifecycle.viewmodel) } } diff --git a/sample/feature-promotions/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/promotions/PromotionsFlagsViewModel.kt b/sample/feature-promotions/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/promotions/PromotionsFlagsViewModel.kt new file mode 100644 index 0000000..659d12d --- /dev/null +++ b/sample/feature-promotions/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/promotions/PromotionsFlagsViewModel.kt @@ -0,0 +1,23 @@ +package dev.androidbroadcast.featured.sample.promotions + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.androidbroadcast.featured.ConfigValues +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +public class PromotionsFlagsViewModel( + private val configValues: ConfigValues, +) : ViewModel() { + public val promoBannerEnabled: StateFlow = + configValues + .promoBannerEnabledFlow() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), false) + // matches default declared in :sample:feature-promotions build.gradle.kts + + public fun setPromoBannerEnabled(value: Boolean) { + viewModelScope.launch { configValues.setPromoBannerEnabled(value) } + } +} diff --git a/sample/feature-ui/build.gradle.kts b/sample/feature-ui/build.gradle.kts index 1bbbe9f..5cb0be5 100644 --- a/sample/feature-ui/build.gradle.kts +++ b/sample/feature-ui/build.gradle.kts @@ -34,6 +34,8 @@ kotlin { commonMain.dependencies { api(project(":core")) api(libs.kotlinx.coroutines.core) + // UiFlagsViewModel and MainButtonColor are in this module's public API surface. + api(libs.androidx.lifecycle.viewmodel) } } diff --git a/sample/feature-ui/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/ui/MainButtonColor.kt b/sample/feature-ui/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/ui/MainButtonColor.kt new file mode 100644 index 0000000..83be512 --- /dev/null +++ b/sample/feature-ui/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/ui/MainButtonColor.kt @@ -0,0 +1,7 @@ +package dev.androidbroadcast.featured.sample.ui + +public sealed interface MainButtonColor { + public data object Red : MainButtonColor + + public data object Blue : MainButtonColor +} diff --git a/sample/feature-ui/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/ui/UiFlagsViewModel.kt b/sample/feature-ui/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/ui/UiFlagsViewModel.kt new file mode 100644 index 0000000..ba0ea7a --- /dev/null +++ b/sample/feature-ui/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/ui/UiFlagsViewModel.kt @@ -0,0 +1,40 @@ +package dev.androidbroadcast.featured.sample.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.androidbroadcast.featured.ConfigValues +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +public class UiFlagsViewModel( + private val configValues: ConfigValues, +) : ViewModel() { + public val mainButtonColor: StateFlow = + configValues + .mainButtonRedFlow() + .map { isRed -> if (isRed) MainButtonColor.Red else MainButtonColor.Blue } + .stateIn( + // matches the default declared in :sample:feature-ui build.gradle.kts + // (main_button_red = true → Red) + initialValue = MainButtonColor.Red, + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000L), + ) + + public val newFeatureSectionEnabled: StateFlow = + configValues + .newFeatureSectionEnabledFlow() + // matches default declared in :sample:feature-ui build.gradle.kts + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), true) + + public fun setMainButtonColor(color: MainButtonColor) { + viewModelScope.launch { configValues.setMainButtonRed(color == MainButtonColor.Red) } + } + + public fun setNewFeatureSectionEnabled(value: Boolean) { + viewModelScope.launch { configValues.setNewFeatureSectionEnabled(value) } + } +} diff --git a/sample/shared/build.gradle.kts b/sample/shared/build.gradle.kts index 4e8f030..a6f4dcd 100644 --- a/sample/shared/build.gradle.kts +++ b/sample/shared/build.gradle.kts @@ -54,13 +54,15 @@ kotlin { implementation(libs.androidx.lifecycle.viewmodelCompose) implementation(libs.androidx.lifecycle.runtimeCompose) - // :core types (ConfigValues, ConfigParam, InMemoryConfigValueProvider) appear in - // the public signatures of SampleApp / SampleViewModel — must be api to compile - // downstream consumers like :sample:desktop. Pre-existing leak from #182. + // :core is used directly in :sample:shared's iosMain (MainViewController.kt) + // for ConfigValues + InMemoryConfigValueProvider construction, and per-feature + // VM constructors take ConfigValues. Kept as api so platform shells reuse the + // transitive chain without re-declaring :core themselves. api(project(":core")) - // CheckoutVariant appears in StateFlow in SampleViewModel public API; - // observe-bridge extensions are called from :sample:android-app / :sample:desktop. + // Per-feature ViewModel types (CheckoutFlagsViewModel, PromotionsFlagsViewModel, + // UiFlagsViewModel) appear in SampleApp's public signature — api so that platform + // shells (:sample:android-app, :sample:desktop, iosMain) can reference them. api(project(":sample:feature-checkout")) api(project(":sample:feature-promotions")) api(project(":sample:feature-ui")) diff --git a/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/FeaturedSample.kt b/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/FeaturedSample.kt index e9cb530..0b7d54c 100644 --- a/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/FeaturedSample.kt +++ b/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/FeaturedSample.kt @@ -26,29 +26,39 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel +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.MainButtonColor +import dev.androidbroadcast.featured.sample.ui.UiFlagsViewModel /** * Main sample screen demonstrating `@LocalFlag` and `@RemoteFlag` usage end-to-end. * - * @param configValues The shared [ConfigValues] instance. + * Each per-feature ViewModel is constructed by the platform shell (Activity, desktop main, + * iOS UIViewController) and passed in explicitly — demonstrating the per-module ConfigValues + * pattern where each module owns its own ConfigValues instance. + * + * @param uiViewModel ViewModel for UI-related flags from :sample:feature-ui. + * @param promotionsViewModel ViewModel for promotions flags from :sample:feature-promotions. + * @param checkoutViewModel ViewModel for checkout flags from :sample:feature-checkout. * @param onOpenDebugUi Callback to navigate to [FeatureFlagsDebugScreen]. * Non-null in debug builds only — the button is absent in release. * @param modifier Optional [Modifier]. */ @Composable -public fun FeaturedSample( - configValues: ConfigValues, +internal fun FeaturedSample( + uiViewModel: UiFlagsViewModel, + promotionsViewModel: PromotionsFlagsViewModel, + checkoutViewModel: CheckoutFlagsViewModel, onOpenDebugUi: (() -> Unit)? = null, modifier: Modifier = Modifier, ) { - val viewModel: SampleViewModel = viewModel { SampleViewModel(configValues) } - val activate by viewModel.flagActive.collectAsStateWithLifecycle() - val buttonColor by viewModel.mainButtonColor.collectAsStateWithLifecycle() - val newFeatureSectionEnabled by viewModel.newFeatureSectionEnabled.collectAsStateWithLifecycle() - val promoBannerEnabled by viewModel.promoBannerEnabled.collectAsStateWithLifecycle() - val checkoutVariant by viewModel.checkoutVariant.collectAsStateWithLifecycle() + val buttonColor by uiViewModel.mainButtonColor.collectAsStateWithLifecycle() + val activate = buttonColor == MainButtonColor.Red + val newFeatureSectionEnabled by uiViewModel.newFeatureSectionEnabled.collectAsStateWithLifecycle() + val promoBannerEnabled by promotionsViewModel.promoBannerEnabled.collectAsStateWithLifecycle() + val checkoutVariant by checkoutViewModel.checkoutVariant.collectAsStateWithLifecycle() Column( modifier = @@ -83,12 +93,16 @@ public fun FeaturedSample( ) { Checkbox( checked = activate, - onCheckedChange = viewModel::setMainButtonColorFlag, + onCheckedChange = { isChecked -> + uiViewModel.setMainButtonColor(if (isChecked) MainButtonColor.Red else MainButtonColor.Blue) + }, ) Text("Enable red button") } MainButton( - onClick = { viewModel.setMainButtonColorFlag(!activate) }, + onClick = { + uiViewModel.setMainButtonColor(if (activate) MainButtonColor.Blue else MainButtonColor.Red) + }, buttonColor = buttonColor, ) @@ -132,9 +146,9 @@ public fun FeaturedSample( } @Composable -public fun MainButton( +private fun MainButton( onClick: () -> Unit, - buttonColor: SampleViewModel.MainButtonColor, + buttonColor: MainButtonColor, modifier: Modifier = Modifier, ) { Button( @@ -143,8 +157,8 @@ public fun MainButton( ButtonDefaults.buttonColors( containerColor = when (buttonColor) { - SampleViewModel.MainButtonColor.Red -> Color.Red - SampleViewModel.MainButtonColor.Blue -> Color.Blue + MainButtonColor.Red -> Color.Red + MainButtonColor.Blue -> Color.Blue }, ), modifier = modifier.fillMaxWidth(), diff --git a/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleApp.kt b/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleApp.kt index 56a9cc0..1137218 100644 --- a/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleApp.kt +++ b/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleApp.kt @@ -4,25 +4,37 @@ package dev.androidbroadcast.featured import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import dev.androidbroadcast.featured.sample.checkout.CheckoutFlagsViewModel +import dev.androidbroadcast.featured.sample.promotions.PromotionsFlagsViewModel +import dev.androidbroadcast.featured.sample.ui.UiFlagsViewModel /** * Root composable for the sample application. * - * [onOpenDebugUi] is non-null in debug builds (wired by the debug source set) + * Each ViewModel corresponds to one feature module's [ConfigValues] instance — + * demonstrating the per-module ConfigValues pattern. + * + * [onOpenDebugUi] is non-null in debug builds (wired by the platform shell) * and null in release builds, so no debug UI entry point is compiled into release. * - * @param configValues The shared [ConfigValues] instance. + * @param uiViewModel ViewModel for UI-related flags from :sample:feature-ui. + * @param promotionsViewModel ViewModel for promotions flags from :sample:feature-promotions. + * @param checkoutViewModel ViewModel for checkout flags from :sample:feature-checkout. * @param onOpenDebugUi Callback to navigate to the debug UI screen. Null in release builds. * @param modifier Optional [Modifier] for the root composable. */ @Composable public fun SampleApp( - configValues: ConfigValues, + uiViewModel: UiFlagsViewModel, + promotionsViewModel: PromotionsFlagsViewModel, + checkoutViewModel: CheckoutFlagsViewModel, onOpenDebugUi: (() -> Unit)? = null, modifier: Modifier = Modifier, ) { FeaturedSample( - configValues = configValues, + uiViewModel = uiViewModel, + promotionsViewModel = promotionsViewModel, + checkoutViewModel = checkoutViewModel, onOpenDebugUi = onOpenDebugUi, modifier = modifier, ) diff --git a/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleViewModel.kt b/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleViewModel.kt deleted file mode 100644 index 5b41d1d..0000000 --- a/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleViewModel.kt +++ /dev/null @@ -1,89 +0,0 @@ -package dev.androidbroadcast.featured - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import dev.androidbroadcast.featured.sample.checkout.CheckoutVariant -import dev.androidbroadcast.featured.sample.checkout.checkoutVariantFlow -import dev.androidbroadcast.featured.sample.checkout.newCheckoutFlow -import dev.androidbroadcast.featured.sample.checkout.setNewCheckout -import dev.androidbroadcast.featured.sample.promotions.promoBannerEnabledFlow -import dev.androidbroadcast.featured.sample.promotions.setPromoBannerEnabled -import dev.androidbroadcast.featured.sample.ui.mainButtonRedFlow -import dev.androidbroadcast.featured.sample.ui.newFeatureSectionEnabledFlow -import dev.androidbroadcast.featured.sample.ui.setMainButtonRed -import dev.androidbroadcast.featured.sample.ui.setNewFeatureSectionEnabled -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch - -public class SampleViewModel( - private val configValues: ConfigValues, -) : ViewModel() { - public val flagActive: StateFlow = - configValues - .mainButtonRedFlow() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), true) - // matches default declared in :sample:feature-ui build.gradle.kts - - public val mainButtonColor: StateFlow = - flagActive - .map { isRed -> - if (isRed) MainButtonColor.Red else MainButtonColor.Blue - }.stateIn( - initialValue = MainButtonColor.Default, - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000L), - ) - - public fun setMainButtonColorFlag(value: Boolean) { - viewModelScope.launch { configValues.setMainButtonRed(value) } - } - - public val newFeatureSectionEnabled: StateFlow = - configValues - .newFeatureSectionEnabledFlow() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), true) - // matches default declared in :sample:feature-ui build.gradle.kts - - public val promoBannerEnabled: StateFlow = - configValues - .promoBannerEnabledFlow() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), false) - // matches default declared in :sample:feature-promotions build.gradle.kts - - public val newCheckout: StateFlow = - configValues - .newCheckoutFlow() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), false) - // matches default declared in :sample:feature-checkout build.gradle.kts - - public val checkoutVariant: StateFlow = - configValues - .checkoutVariantFlow() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), CheckoutVariant.LEGACY) - // matches default declared in :sample:feature-checkout build.gradle.kts - - public fun setNewFeatureSectionEnabled(value: Boolean) { - viewModelScope.launch { configValues.setNewFeatureSectionEnabled(value) } - } - - public fun setPromoBannerEnabled(value: Boolean) { - viewModelScope.launch { configValues.setPromoBannerEnabled(value) } - } - - public fun setNewCheckout(value: Boolean) { - viewModelScope.launch { configValues.setNewCheckout(value) } - } - - public sealed interface MainButtonColor { - public data object Red : MainButtonColor - - public data object Blue : MainButtonColor - - public companion object Companion { - public val Default: MainButtonColor = Blue - } - } -} diff --git a/sample/shared/src/iosMain/kotlin/dev/androidbroadcast/featured/MainViewController.kt b/sample/shared/src/iosMain/kotlin/dev/androidbroadcast/featured/MainViewController.kt index 254a6b0..64a0715 100644 --- a/sample/shared/src/iosMain/kotlin/dev/androidbroadcast/featured/MainViewController.kt +++ b/sample/shared/src/iosMain/kotlin/dev/androidbroadcast/featured/MainViewController.kt @@ -1,15 +1,37 @@ -@file:Suppress("RedundantVisibilityModifier", "ktlint:standard:function-naming") +@file:Suppress("ktlint:standard:function-naming") package dev.androidbroadcast.featured import androidx.compose.ui.window.ComposeUIViewController +import dev.androidbroadcast.featured.sample.checkout.CheckoutFlagsViewModel +import dev.androidbroadcast.featured.sample.promotions.PromotionsFlagsViewModel +import dev.androidbroadcast.featured.sample.ui.UiFlagsViewModel import platform.UIKit.UIViewController -// ConfigValues is constructed once per UIViewController and passed explicitly. -// In a real app this instance would come from a shared DI container. +// Each feature module gets its own ConfigValues backed by the same in-memory provider. +// Per-module ConfigValues is the pattern Featured is designed around: flags are scoped +// to the module that declared them. +// In a real app these instances would come from a shared DI container. public fun MainViewController(): UIViewController { - val configValues = ConfigValues(localProvider = InMemoryConfigValueProvider()) + val sharedLocalProvider = InMemoryConfigValueProvider() + + val checkoutConfigValues = ConfigValues(localProvider = sharedLocalProvider) + val promotionsConfigValues = ConfigValues(localProvider = sharedLocalProvider) + val uiConfigValues = ConfigValues(localProvider = sharedLocalProvider) + // No debug aggregator on iOS — the iOS shell does not wire a debug-UI entry. + // The Android shell builds a fourth `ConfigValues` for the debug screen. + + // VMs are constructed once per UIViewController — ConfigValues lifetimes are tied + // to this root view controller's lifetime. + val checkoutViewModel = CheckoutFlagsViewModel(checkoutConfigValues) + val promotionsViewModel = PromotionsFlagsViewModel(promotionsConfigValues) + val uiViewModel = UiFlagsViewModel(uiConfigValues) + return ComposeUIViewController { - SampleApp(configValues = configValues) + SampleApp( + uiViewModel = uiViewModel, + promotionsViewModel = promotionsViewModel, + checkoutViewModel = checkoutViewModel, + ) } } From 5ba03d783743add1d708a02197e1c7e6fac7ab76 Mon Sep 17 00:00:00 2001 From: Kirill Rozov Date: Thu, 21 May 2026 19:05:00 +0300 Subject: [PATCH 13/15] Move Gradle plugin from build-logic/ to repo root (#215) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build-logic/ is the conventional location for internal convention plugins, not a published Maven Central artifact. Moves the plugin to its own included build at featured-gradle-plugin/ (the previously empty directory), matching the standard OSS Gradle plugin layout. Dogfooding on :sample:feature-* remains via includeBuild(...) — no functional change. Removes the empty build-logic/ wrapper. Updates root settings.gradle.kts, build.gradle.kts and CLAUDE.md to reflect the new structure. --- CLAUDE.md | 120 ++++++++++++++++-- build.gradle.kts | 4 +- .../CLAUDE.md | 0 .../README.md | 0 .../build.gradle.kts | 0 .../settings.gradle.kts | 3 +- .../featured/gradle/AndroidProguardWiring.kt | 0 .../featured/gradle/ConfigParamGenerator.kt | 0 .../gradle/ExtensionFunctionGenerator.kt | 0 .../gradle/FeaturedApplicationPlugin.kt | 0 .../featured/gradle/FeaturedExtension.kt | 0 .../featured/gradle/FeaturedPlugin.kt | 0 .../featured/gradle/FlagContainer.kt | 0 .../featured/gradle/FlagEntryUtils.kt | 0 .../featured/gradle/FlagSpec.kt | 0 .../gradle/GenerateConfigParamTask.kt | 0 .../gradle/GenerateIosConstValTask.kt | 0 .../gradle/GenerateProguardRulesTask.kt | 0 .../featured/gradle/GenerateXcconfigTask.kt | 0 .../featured/gradle/IosConstValGenerator.kt | 0 .../featured/gradle/LocalFlagEntry.kt | 0 .../featured/gradle/ProguardRulesGenerator.kt | 0 .../featured/gradle/ResolveFlagsTask.kt | 0 .../featured/gradle/ScanResultParser.kt | 0 .../featured/gradle/XcconfigGenerator.kt | 0 .../gradle/aggregation/AggregationContract.kt | 0 .../GenerateFeaturedRegistryTask.kt | 0 .../GeneratedFeaturedRegistryGenerator.kt | 0 .../gradle/manifest/FeaturedManifest.kt | 0 .../manifest/FeaturedManifestContract.kt | 0 .../manifest/GenerateFeaturedManifestTask.kt | 0 .../app/build.gradle.kts | 0 .../app/src/main/AndroidManifest.xml | 0 .../build.gradle.kts | 0 .../feature-checkout/build.gradle.kts | 0 .../src/main/AndroidManifest.xml | 0 .../feature-profile/build.gradle.kts | 0 .../src/main/AndroidManifest.xml | 0 .../gradle.properties | 0 .../settings.gradle.kts | 0 .../fixtures/android-project/build.gradle.kts | 0 .../android-project/settings.gradle.kts | 0 .../src/main/AndroidManifest.xml | 0 .../featured/testapp/CheckoutVariant.kt | 0 .../build.gradle.kts | 0 .../settings.gradle.kts | 0 .../kmp-publish-project/build.gradle.kts | 0 .../kmp-publish-project/gradle.properties | 0 .../module/build.gradle.kts | 0 .../module/src/commonMain/kotlin/.gitkeep | 0 .../kmp-publish-project/settings.gradle.kts | 0 .../app/build.gradle.kts | 0 .../app/src/main/AndroidManifest.xml | 0 .../manifest-publish-project/build.gradle.kts | 0 .../gradle.properties | 0 .../settings.gradle.kts | 0 .../gradle/ConfigParamGeneratorTest.kt | 0 .../gradle/ExtensionFunctionGeneratorTest.kt | 0 .../gradle/FeaturedPluginIntegrationTest.kt | 0 .../featured/gradle/FeaturedPluginTest.kt | 0 .../featured/gradle/FlagContainerTest.kt | 0 .../featured/gradle/FlagEntryUtilsTest.kt | 0 ...GenerateIosConstValTaskRegistrationTest.kt | 0 ...nerateProguardRulesTaskRegistrationTest.kt | 0 .../GenerateXcconfigTaskRegistrationTest.kt | 0 .../gradle/IosConstValGeneratorTest.kt | 0 .../featured/gradle/LocalFlagEntryTest.kt | 0 .../gradle/ProguardRulesGeneratorTest.kt | 0 .../featured/gradle/XcconfigGeneratorTest.kt | 0 .../FeaturedAggregationConfigurationTest.kt | 0 ...turedAggregationDescriptorIntegrityTest.kt | 0 .../FeaturedAggregationDuplicateKeyTest.kt | 0 .../FeaturedAggregationIntegrationTest.kt | 0 .../FeaturedAggregationParseErrorTest.kt | 0 ...ateFeaturedRegistryTaskRegistrationTest.kt | 0 .../GeneratedFeaturedRegistryGeneratorTest.kt | 0 .../manifest/FeaturedKmpPublicationTest.kt | 0 .../FeaturedManifestConfigurationTest.kt | 0 .../manifest/FeaturedManifestEmptyDslTest.kt | 0 .../FeaturedManifestIntegrationTest.kt | 0 .../manifest/FeaturedManifestMappingTest.kt | 0 .../FeaturedManifestSerializationTest.kt | 0 ...ateFeaturedManifestTaskRegistrationTest.kt | 0 .../gradle/manifest/TestFixtureSupport.kt | 0 settings.gradle.kts | 2 +- 85 files changed, 114 insertions(+), 15 deletions(-) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/CLAUDE.md (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/README.md (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/build.gradle.kts (100%) rename {build-logic => featured-gradle-plugin}/settings.gradle.kts (96%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/main/kotlin/dev/androidbroadcast/featured/gradle/AndroidProguardWiring.kt (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/main/kotlin/dev/androidbroadcast/featured/gradle/ConfigParamGenerator.kt (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/main/kotlin/dev/androidbroadcast/featured/gradle/ExtensionFunctionGenerator.kt (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedApplicationPlugin.kt (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedExtension.kt (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPlugin.kt (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/main/kotlin/dev/androidbroadcast/featured/gradle/FlagContainer.kt (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/main/kotlin/dev/androidbroadcast/featured/gradle/FlagEntryUtils.kt (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/main/kotlin/dev/androidbroadcast/featured/gradle/FlagSpec.kt (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateConfigParamTask.kt (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateIosConstValTask.kt (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateProguardRulesTask.kt (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateXcconfigTask.kt (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/main/kotlin/dev/androidbroadcast/featured/gradle/IosConstValGenerator.kt (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/main/kotlin/dev/androidbroadcast/featured/gradle/LocalFlagEntry.kt (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/main/kotlin/dev/androidbroadcast/featured/gradle/ProguardRulesGenerator.kt (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/main/kotlin/dev/androidbroadcast/featured/gradle/ResolveFlagsTask.kt (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/main/kotlin/dev/androidbroadcast/featured/gradle/ScanResultParser.kt (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/main/kotlin/dev/androidbroadcast/featured/gradle/XcconfigGenerator.kt (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/AggregationContract.kt (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GenerateFeaturedRegistryTask.kt (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GeneratedFeaturedRegistryGenerator.kt (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifest.kt (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestContract.kt (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/GenerateFeaturedManifestTask.kt (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/test/fixtures/aggregator-multi-module-project/app/build.gradle.kts (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/test/fixtures/aggregator-multi-module-project/app/src/main/AndroidManifest.xml (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/test/fixtures/aggregator-multi-module-project/build.gradle.kts (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/test/fixtures/aggregator-multi-module-project/feature-checkout/build.gradle.kts (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/test/fixtures/aggregator-multi-module-project/feature-checkout/src/main/AndroidManifest.xml (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/test/fixtures/aggregator-multi-module-project/feature-profile/build.gradle.kts (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/test/fixtures/aggregator-multi-module-project/feature-profile/src/main/AndroidManifest.xml (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/test/fixtures/aggregator-multi-module-project/gradle.properties (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/test/fixtures/aggregator-multi-module-project/settings.gradle.kts (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/test/fixtures/android-project/build.gradle.kts (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/test/fixtures/android-project/settings.gradle.kts (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/test/fixtures/android-project/src/main/AndroidManifest.xml (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/test/fixtures/android-project/src/main/kotlin/dev/androidbroadcast/featured/testapp/CheckoutVariant.kt (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/test/fixtures/jvm-empty-featured-project/build.gradle.kts (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/test/fixtures/jvm-empty-featured-project/settings.gradle.kts (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/test/fixtures/kmp-publish-project/build.gradle.kts (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/test/fixtures/kmp-publish-project/gradle.properties (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/test/fixtures/kmp-publish-project/module/build.gradle.kts (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/test/fixtures/kmp-publish-project/module/src/commonMain/kotlin/.gitkeep (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/test/fixtures/kmp-publish-project/settings.gradle.kts (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/test/fixtures/manifest-publish-project/app/build.gradle.kts (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/test/fixtures/manifest-publish-project/app/src/main/AndroidManifest.xml (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/test/fixtures/manifest-publish-project/build.gradle.kts (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/test/fixtures/manifest-publish-project/gradle.properties (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/test/fixtures/manifest-publish-project/settings.gradle.kts (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/ConfigParamGeneratorTest.kt (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/ExtensionFunctionGeneratorTest.kt (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPluginIntegrationTest.kt (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPluginTest.kt (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/FlagContainerTest.kt (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/FlagEntryUtilsTest.kt (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/GenerateIosConstValTaskRegistrationTest.kt (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/GenerateProguardRulesTaskRegistrationTest.kt (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/GenerateXcconfigTaskRegistrationTest.kt (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/IosConstValGeneratorTest.kt (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/LocalFlagEntryTest.kt (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/ProguardRulesGeneratorTest.kt (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/XcconfigGeneratorTest.kt (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationConfigurationTest.kt (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationDescriptorIntegrityTest.kt (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationDuplicateKeyTest.kt (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationIntegrationTest.kt (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationParseErrorTest.kt (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GenerateFeaturedRegistryTaskRegistrationTest.kt (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GeneratedFeaturedRegistryGeneratorTest.kt (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedKmpPublicationTest.kt (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestConfigurationTest.kt (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestEmptyDslTest.kt (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestIntegrationTest.kt (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestMappingTest.kt (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestSerializationTest.kt (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/GenerateFeaturedManifestTaskRegistrationTest.kt (100%) rename {build-logic/featured-gradle-plugin => featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/TestFixtureSupport.kt (100%) diff --git a/CLAUDE.md b/CLAUDE.md index 49ab66f..1bbdbd0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,19 +4,119 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -**Featured** is a Kotlin Multiplatform (KMP) configuration management library supporting Android, iOS (via SKIE), and JVM. It provides a type-safe, reactive configuration system with swappable local and remote providers. +**Featured** is a Kotlin Multiplatform feature-flag / configuration management library (Android, iOS via SKIE, JVM / Compose Desktop). Two things make it more than a typed wrapper around shared-prefs: + +1. **A Gradle plugin family** generates typed `ConfigParam` objects + ergonomic `ConfigValues` extensions from a `featured { localFlags { … } }` DSL — no hand-written keys. +2. **Release-time dead-code elimination.** Flags with `default = false` emit per-function R8 `-assumevalues` rules (Android/JVM) and an xcconfig with `DISABLE_` Swift conditions (iOS). Disabled branches are physically stripped from release binaries. + +`develop` is the integration branch; PRs target `develop`, not `main`. ## Core Concepts -- **`ConfigParam`** — declares a named, typed configuration key with a default value -- **`ConfigValue`** — wraps a param + its current value; supports reactive observation via `Flow` -- **`ConfigValues`** — container holding all `ConfigValue` instances; accepts optional local and remote providers -- **`LocalConfigValueProvider` / `RemoteConfigValueProvider`** — interfaces implemented by each provider module +- **`ConfigParam`** — declared name + typed default. The Gradle plugin emits these as `object GeneratedLocalFlagsX` / `GeneratedRemoteFlagsX` per-module (since PR #202: **`internal`**, not public). +- **`ConfigValue`** — observable value (`Flow`) for a single `ConfigParam`. +- **`ConfigValues`** — container; constructed with optional `LocalConfigValueProvider` and `RemoteConfigValueProvider`. **Remote overrides local.** Apps normally construct **one `ConfigValues` per feature module**, all sharing the same provider. +- **Aggregator plugin (`dev.androidbroadcast.featured.application`)** — consumes `featured-manifest.json` from every `featuredAggregation(project(...))` dependency and generates `GeneratedFeaturedRegistry.all: List>`. This is the *only* cross-module flag listing surface; the per-module generated objects stay `internal`. +- **Observe-bridge convention** — each feature module ships public `ConfigValues` extensions (`fooFlow()`, `setFoo()`) so the UI never references `GeneratedLocalFlagsX` directly. + +## Module Map + +``` +core ───────────────── public abstractions (ConfigParam/Value/Values, provider interfaces) +featured-compose ──── Compose-Multiplatform extension (collectAsState helpers) +featured-debug-ui ─── FeatureFlagsDebugScreen (UI-agnostic, reads GeneratedFeaturedRegistry.all) +featured-testing ──── test doubles (InMemoryConfigValueProvider, etc.) +featured-platform ─── platform metadata module +featured-bom ──────── Maven BOM +featured-detekt-rules / featured-lint-rules ── static checks for flag misuse +featured-shrinker-tests ── R8 DCE integration tests +providers/{configcat,datastore,firebase,javaprefs,nsuserdefaults,sharedpreferences} +featured-gradle-plugin/ ── published Gradle plugin (included build, dogfooded on :sample:feature-*) +sample/{shared,feature-checkout,feature-promotions,feature-ui,android-app,desktop} +iosApp/ Swift consumer of FeaturedSampleApp.framework +``` + +The plugin is structured as an **included build** (`includeBuild("featured-gradle-plugin")` in the root `settings.gradle.kts`), not a regular subproject. This breaks the chicken-and-egg cycle of applying the plugin to `:sample:feature-*` modules within the same repo for dogfooding. + +## Build / Test Commands + +```bash +./gradlew assemble # build everything +./gradlew test # all JVM unit tests +./gradlew :core:test # one module +./gradlew :core:test --tests "dev.androidbroadcast.featured.ConfigValuesTest" +./gradlew :core:koverVerify # core requires >=90% line coverage +./gradlew :core:koverHtmlReport +./gradlew :core:connectedAndroidTest # needs device/emulator +./gradlew :featured-debug-ui:allTests # KMP module — JVM + Android + iOS targets +./gradlew :featured-gradle-plugin:test # plugin unit tests (43+ cases) +./gradlew spotlessCheck # required before push +./gradlew spotlessApply # auto-fix +./gradlew publishToMavenLocal # publish the Gradle plugin locally +``` + +Sample build / install: + +```bash +./gradlew :sample:android-app:installDebug # Android sample +./gradlew :sample:desktop:run # Compose Desktop sample +``` + +**Plugin codegen tasks (per-module, when the project applies `dev.androidbroadcast.featured`):** + +- `generateConfigParam` — typed `ConfigParam` objects + `ConfigValues` extensions +- `generateFeaturedProguardRules` — R8 `-assumevalues` rules for local flags +- `generateIosConstVal` / `generateXcconfig` — Swift DCE inputs +- `generateFeaturedManifest` — emits `featured-manifest.json` consumed by the aggregator +- `generateFeaturedRegistry` (aggregator-only) — produces `GeneratedFeaturedRegistry.kt` + +## Plugin Architecture (highest-leverage to understand) + +Two plugins, two roles: + +| Plugin ID | Where | Role | +|---|---|---| +| `dev.androidbroadcast.featured` | every feature / library module that declares flags | Exposes the `featured { }` DSL; generates per-module `ConfigParam` objects, observe extensions, ProGuard rules, iOS const-val + xcconfig, and a `featured-manifest.json` artifact (consumable Gradle variant `featured-manifest`). | +| `dev.androidbroadcast.featured.application` | the app / aggregator module only | Adds a `featuredAggregation` `dependencyScope` configuration. Resolves the `featured-manifest` variant from each declared project dep, merges them, and generates `GeneratedFeaturedRegistry.all`. **Min Gradle 8.5+** (uses `dependencyScope` / `resolvable` API). | + +**Enum-flag classpath gotcha.** `featuredAggregation(project(":foo"))` only pulls the manifest variant — not `:foo`'s compile classpath. If `:foo` declares an `enum` flag whose enum type lives in `:foo`, the aggregator module must also declare `implementation(project(":foo"))` so the enum class is visible at compile time. Primitive-only modules need no extra dependency. + +**Auto-wiring policy.** The aggregator does **not** auto-wire its output into a source set — the consumer module wires it manually because the plugin can't safely assume KMP vs. AGP vs. plain JVM: + +```kotlin +kotlin.sourceSets.getByName("commonMain").kotlin.srcDir( + tasks.named("generateFeaturedRegistry").map { it.outputs.files.singleFile.parentFile } +) +``` + +## Multi-Module Pattern (canonical, demonstrated in `:sample`) + +Real apps with N feature modules wire **N production `ConfigValues`** (one per feature), all sharing one `LocalConfigValueProvider`. The shell additionally builds one extra `ConfigValues` for `FeatureFlagsDebugScreen` (Android only in the sample; Desktop/iOS omit the debug surface). Each feature module: + +1. Declares its flags in its own `build.gradle.kts: featured { localFlags { … } }`. +2. Exposes public `*FlagObservers.kt` extensions on `ConfigValues` (the only sanctioned cross-module API surface). +3. Owns its own `*FlagsViewModel` taking only its own `ConfigValues`. + +`GeneratedLocalFlagsX` / `GeneratedRemoteFlagsX` are `internal` to their module — never reference them across module boundaries. Use `GeneratedFeaturedRegistry.all` for cross-module flag listing. + +For non-reactive reads (logging, eager-conditional paths) use `configValues.getValueCached(param)` — the generated `isFooEnabled()` / `getFoo()` extensions are non-suspend and delegate to it (PR #201 restored this synchronous path; R8 DCE depends on it). + +## Project Conventions -**Provider priority:** remote values override local values when both are present. +- **Explicit API mode** is on for every KMP module — all public declarations need explicit visibility. Generated flag objects are deliberately `internal`. +- **Version catalog** (`gradle/libs.versions.toml`) is the single source of truth for dependency versions. +- **Spotless / ktlint** runs over `**/*.kt` and `**/*.kts` excluding `build/`. CI fails on `spotlessCheck`. +- **Binary Compatibility Validator** enforces public-API stability — a public-surface change without `apiDump` update fails CI. Featured has **no migration window** for breaking changes; breaking changes go in directly, the version number reflects it. +- **Branching:** `develop` is the integration branch; PRs go to `develop`, not `main`. `main` is updated only on releases. One logical change per PR — do not bundle. +- **Comment language:** English (per `.github/copilot-instructions.md`). +- **iOS:** SKIE is applied in `:core`; the XCFramework is named `FeaturedCore`. SKIE config is `skie.toml` at repo root. +- **R8:** the project relies on `android.enableR8.fullMode=true` and `android.r8.strictInputValidation=true`. The generated ProGuard rules + `-assumevalues` are what make DCE work. -## Key Conventions +## Where to Look First When… -- **Explicit API mode** on all KMP modules — every public declaration requires an explicit visibility modifier -- **Version catalog** (`gradle/libs.versions.toml`) is the single source of truth for dependency versions -- **Formatting:** run `./gradlew spotlessCheck` before pushing; `./gradlew spotlessApply` to auto-fix +- "Find how the DSL is parsed" → `featured-gradle-plugin/src/main/kotlin/.../FeaturedExtension.kt`, `FlagSpec.kt`, `FlagContainer.kt`. +- "Find codegen output shape" → `ConfigParamGenerator.kt`, `ExtensionFunctionGenerator.kt`, `ProguardRulesGenerator.kt`, `XcconfigGenerator.kt`, `IosConstValGenerator.kt` (all in `featured-gradle-plugin/src/main/kotlin/`). +- "Find aggregator wiring" → `FeaturedApplicationPlugin.kt` + `aggregation/` subpackage. +- "Find manifest format" → `manifest/` subpackage (`GenerateFeaturedManifestTask.kt`, `SCHEMA_VERSION`). +- "Verify R8 DCE behaviour" → `featured-shrinker-tests/` (integration tests over real `assembleRelease`). +- Sample wiring → `sample/android-app`, `sample/desktop`, `iosApp/`; per-feature flag observers → `sample/feature-*/.../*FlagObservers.kt`. diff --git a/build.gradle.kts b/build.gradle.kts index f26360a..06558d4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -15,11 +15,11 @@ plugins { } tasks.register("publishToMavenCentral") { - dependsOn(gradle.includedBuild("build-logic").task(":featured-gradle-plugin:publishToMavenCentral")) + dependsOn(gradle.includedBuild("featured-gradle-plugin").task(":publishToMavenCentral")) } tasks.register("publishToMavenLocal") { - dependsOn(gradle.includedBuild("build-logic").task(":featured-gradle-plugin:publishToMavenLocal")) + dependsOn(gradle.includedBuild("featured-gradle-plugin").task(":publishToMavenLocal")) } spotless { diff --git a/build-logic/featured-gradle-plugin/CLAUDE.md b/featured-gradle-plugin/CLAUDE.md similarity index 100% rename from build-logic/featured-gradle-plugin/CLAUDE.md rename to featured-gradle-plugin/CLAUDE.md diff --git a/build-logic/featured-gradle-plugin/README.md b/featured-gradle-plugin/README.md similarity index 100% rename from build-logic/featured-gradle-plugin/README.md rename to featured-gradle-plugin/README.md diff --git a/build-logic/featured-gradle-plugin/build.gradle.kts b/featured-gradle-plugin/build.gradle.kts similarity index 100% rename from build-logic/featured-gradle-plugin/build.gradle.kts rename to featured-gradle-plugin/build.gradle.kts diff --git a/build-logic/settings.gradle.kts b/featured-gradle-plugin/settings.gradle.kts similarity index 96% rename from build-logic/settings.gradle.kts rename to featured-gradle-plugin/settings.gradle.kts index 201ab5a..b48e411 100644 --- a/build-logic/settings.gradle.kts +++ b/featured-gradle-plugin/settings.gradle.kts @@ -57,5 +57,4 @@ gradle.beforeProject { (parentProps.getProperty("VERSION_NAME") ?: "unspecified").let { version = it } } -rootProject.name = "build-logic" -include(":featured-gradle-plugin") +rootProject.name = "featured-gradle-plugin" diff --git a/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/AndroidProguardWiring.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/AndroidProguardWiring.kt similarity index 100% rename from build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/AndroidProguardWiring.kt rename to featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/AndroidProguardWiring.kt diff --git a/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ConfigParamGenerator.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ConfigParamGenerator.kt similarity index 100% rename from build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ConfigParamGenerator.kt rename to featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ConfigParamGenerator.kt diff --git a/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ExtensionFunctionGenerator.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ExtensionFunctionGenerator.kt similarity index 100% rename from build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ExtensionFunctionGenerator.kt rename to featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ExtensionFunctionGenerator.kt diff --git a/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedApplicationPlugin.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedApplicationPlugin.kt similarity index 100% rename from build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedApplicationPlugin.kt rename to featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedApplicationPlugin.kt diff --git a/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedExtension.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedExtension.kt similarity index 100% rename from build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedExtension.kt rename to featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedExtension.kt diff --git a/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPlugin.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPlugin.kt similarity index 100% rename from build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPlugin.kt rename to featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPlugin.kt diff --git a/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FlagContainer.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FlagContainer.kt similarity index 100% rename from build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FlagContainer.kt rename to featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FlagContainer.kt diff --git a/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FlagEntryUtils.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FlagEntryUtils.kt similarity index 100% rename from build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FlagEntryUtils.kt rename to featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FlagEntryUtils.kt diff --git a/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FlagSpec.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FlagSpec.kt similarity index 100% rename from build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FlagSpec.kt rename to featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FlagSpec.kt diff --git a/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateConfigParamTask.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateConfigParamTask.kt similarity index 100% rename from build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateConfigParamTask.kt rename to featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateConfigParamTask.kt diff --git a/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateIosConstValTask.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateIosConstValTask.kt similarity index 100% rename from build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateIosConstValTask.kt rename to featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateIosConstValTask.kt diff --git a/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateProguardRulesTask.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateProguardRulesTask.kt similarity index 100% rename from build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateProguardRulesTask.kt rename to featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateProguardRulesTask.kt diff --git a/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateXcconfigTask.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateXcconfigTask.kt similarity index 100% rename from build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateXcconfigTask.kt rename to featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateXcconfigTask.kt diff --git a/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/IosConstValGenerator.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/IosConstValGenerator.kt similarity index 100% rename from build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/IosConstValGenerator.kt rename to featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/IosConstValGenerator.kt diff --git a/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/LocalFlagEntry.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/LocalFlagEntry.kt similarity index 100% rename from build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/LocalFlagEntry.kt rename to featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/LocalFlagEntry.kt diff --git a/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ProguardRulesGenerator.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ProguardRulesGenerator.kt similarity index 100% rename from build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ProguardRulesGenerator.kt rename to featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ProguardRulesGenerator.kt diff --git a/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ResolveFlagsTask.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ResolveFlagsTask.kt similarity index 100% rename from build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ResolveFlagsTask.kt rename to featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ResolveFlagsTask.kt diff --git a/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ScanResultParser.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ScanResultParser.kt similarity index 100% rename from build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ScanResultParser.kt rename to featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ScanResultParser.kt diff --git a/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/XcconfigGenerator.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/XcconfigGenerator.kt similarity index 100% rename from build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/XcconfigGenerator.kt rename to featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/XcconfigGenerator.kt diff --git a/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/AggregationContract.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/AggregationContract.kt similarity index 100% rename from build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/AggregationContract.kt rename to featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/AggregationContract.kt diff --git a/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GenerateFeaturedRegistryTask.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GenerateFeaturedRegistryTask.kt similarity index 100% rename from build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GenerateFeaturedRegistryTask.kt rename to featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GenerateFeaturedRegistryTask.kt diff --git a/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GeneratedFeaturedRegistryGenerator.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GeneratedFeaturedRegistryGenerator.kt similarity index 100% rename from build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GeneratedFeaturedRegistryGenerator.kt rename to featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GeneratedFeaturedRegistryGenerator.kt diff --git a/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifest.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifest.kt similarity index 100% rename from build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifest.kt rename to featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifest.kt diff --git a/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestContract.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestContract.kt similarity index 100% rename from build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestContract.kt rename to featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestContract.kt diff --git a/build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/GenerateFeaturedManifestTask.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/GenerateFeaturedManifestTask.kt similarity index 100% rename from build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/GenerateFeaturedManifestTask.kt rename to featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/GenerateFeaturedManifestTask.kt diff --git a/build-logic/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/app/build.gradle.kts b/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/app/build.gradle.kts similarity index 100% rename from build-logic/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/app/build.gradle.kts rename to featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/app/build.gradle.kts diff --git a/build-logic/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/app/src/main/AndroidManifest.xml b/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/app/src/main/AndroidManifest.xml similarity index 100% rename from build-logic/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/app/src/main/AndroidManifest.xml rename to featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/app/src/main/AndroidManifest.xml diff --git a/build-logic/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/build.gradle.kts b/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/build.gradle.kts similarity index 100% rename from build-logic/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/build.gradle.kts rename to featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/build.gradle.kts diff --git a/build-logic/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-checkout/build.gradle.kts b/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-checkout/build.gradle.kts similarity index 100% rename from build-logic/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-checkout/build.gradle.kts rename to featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-checkout/build.gradle.kts diff --git a/build-logic/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-checkout/src/main/AndroidManifest.xml b/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-checkout/src/main/AndroidManifest.xml similarity index 100% rename from build-logic/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-checkout/src/main/AndroidManifest.xml rename to featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-checkout/src/main/AndroidManifest.xml diff --git a/build-logic/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-profile/build.gradle.kts b/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-profile/build.gradle.kts similarity index 100% rename from build-logic/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-profile/build.gradle.kts rename to featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-profile/build.gradle.kts diff --git a/build-logic/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-profile/src/main/AndroidManifest.xml b/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-profile/src/main/AndroidManifest.xml similarity index 100% rename from build-logic/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-profile/src/main/AndroidManifest.xml rename to featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-profile/src/main/AndroidManifest.xml diff --git a/build-logic/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/gradle.properties b/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/gradle.properties similarity index 100% rename from build-logic/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/gradle.properties rename to featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/gradle.properties diff --git a/build-logic/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/settings.gradle.kts b/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/settings.gradle.kts similarity index 100% rename from build-logic/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/settings.gradle.kts rename to featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/settings.gradle.kts diff --git a/build-logic/featured-gradle-plugin/src/test/fixtures/android-project/build.gradle.kts b/featured-gradle-plugin/src/test/fixtures/android-project/build.gradle.kts similarity index 100% rename from build-logic/featured-gradle-plugin/src/test/fixtures/android-project/build.gradle.kts rename to featured-gradle-plugin/src/test/fixtures/android-project/build.gradle.kts diff --git a/build-logic/featured-gradle-plugin/src/test/fixtures/android-project/settings.gradle.kts b/featured-gradle-plugin/src/test/fixtures/android-project/settings.gradle.kts similarity index 100% rename from build-logic/featured-gradle-plugin/src/test/fixtures/android-project/settings.gradle.kts rename to featured-gradle-plugin/src/test/fixtures/android-project/settings.gradle.kts diff --git a/build-logic/featured-gradle-plugin/src/test/fixtures/android-project/src/main/AndroidManifest.xml b/featured-gradle-plugin/src/test/fixtures/android-project/src/main/AndroidManifest.xml similarity index 100% rename from build-logic/featured-gradle-plugin/src/test/fixtures/android-project/src/main/AndroidManifest.xml rename to featured-gradle-plugin/src/test/fixtures/android-project/src/main/AndroidManifest.xml diff --git a/build-logic/featured-gradle-plugin/src/test/fixtures/android-project/src/main/kotlin/dev/androidbroadcast/featured/testapp/CheckoutVariant.kt b/featured-gradle-plugin/src/test/fixtures/android-project/src/main/kotlin/dev/androidbroadcast/featured/testapp/CheckoutVariant.kt similarity index 100% rename from build-logic/featured-gradle-plugin/src/test/fixtures/android-project/src/main/kotlin/dev/androidbroadcast/featured/testapp/CheckoutVariant.kt rename to featured-gradle-plugin/src/test/fixtures/android-project/src/main/kotlin/dev/androidbroadcast/featured/testapp/CheckoutVariant.kt diff --git a/build-logic/featured-gradle-plugin/src/test/fixtures/jvm-empty-featured-project/build.gradle.kts b/featured-gradle-plugin/src/test/fixtures/jvm-empty-featured-project/build.gradle.kts similarity index 100% rename from build-logic/featured-gradle-plugin/src/test/fixtures/jvm-empty-featured-project/build.gradle.kts rename to featured-gradle-plugin/src/test/fixtures/jvm-empty-featured-project/build.gradle.kts diff --git a/build-logic/featured-gradle-plugin/src/test/fixtures/jvm-empty-featured-project/settings.gradle.kts b/featured-gradle-plugin/src/test/fixtures/jvm-empty-featured-project/settings.gradle.kts similarity index 100% rename from build-logic/featured-gradle-plugin/src/test/fixtures/jvm-empty-featured-project/settings.gradle.kts rename to featured-gradle-plugin/src/test/fixtures/jvm-empty-featured-project/settings.gradle.kts diff --git a/build-logic/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/build.gradle.kts b/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/build.gradle.kts similarity index 100% rename from build-logic/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/build.gradle.kts rename to featured-gradle-plugin/src/test/fixtures/kmp-publish-project/build.gradle.kts diff --git a/build-logic/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/gradle.properties b/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/gradle.properties similarity index 100% rename from build-logic/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/gradle.properties rename to featured-gradle-plugin/src/test/fixtures/kmp-publish-project/gradle.properties diff --git a/build-logic/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/module/build.gradle.kts b/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/module/build.gradle.kts similarity index 100% rename from build-logic/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/module/build.gradle.kts rename to featured-gradle-plugin/src/test/fixtures/kmp-publish-project/module/build.gradle.kts diff --git a/build-logic/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/module/src/commonMain/kotlin/.gitkeep b/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/module/src/commonMain/kotlin/.gitkeep similarity index 100% rename from build-logic/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/module/src/commonMain/kotlin/.gitkeep rename to featured-gradle-plugin/src/test/fixtures/kmp-publish-project/module/src/commonMain/kotlin/.gitkeep diff --git a/build-logic/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/settings.gradle.kts b/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/settings.gradle.kts similarity index 100% rename from build-logic/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/settings.gradle.kts rename to featured-gradle-plugin/src/test/fixtures/kmp-publish-project/settings.gradle.kts diff --git a/build-logic/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/app/build.gradle.kts b/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/app/build.gradle.kts similarity index 100% rename from build-logic/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/app/build.gradle.kts rename to featured-gradle-plugin/src/test/fixtures/manifest-publish-project/app/build.gradle.kts diff --git a/build-logic/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/app/src/main/AndroidManifest.xml b/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/app/src/main/AndroidManifest.xml similarity index 100% rename from build-logic/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/app/src/main/AndroidManifest.xml rename to featured-gradle-plugin/src/test/fixtures/manifest-publish-project/app/src/main/AndroidManifest.xml diff --git a/build-logic/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/build.gradle.kts b/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/build.gradle.kts similarity index 100% rename from build-logic/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/build.gradle.kts rename to featured-gradle-plugin/src/test/fixtures/manifest-publish-project/build.gradle.kts diff --git a/build-logic/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/gradle.properties b/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/gradle.properties similarity index 100% rename from build-logic/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/gradle.properties rename to featured-gradle-plugin/src/test/fixtures/manifest-publish-project/gradle.properties diff --git a/build-logic/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/settings.gradle.kts b/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/settings.gradle.kts similarity index 100% rename from build-logic/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/settings.gradle.kts rename to featured-gradle-plugin/src/test/fixtures/manifest-publish-project/settings.gradle.kts diff --git a/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ConfigParamGeneratorTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ConfigParamGeneratorTest.kt similarity index 100% rename from build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ConfigParamGeneratorTest.kt rename to featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ConfigParamGeneratorTest.kt diff --git a/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ExtensionFunctionGeneratorTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ExtensionFunctionGeneratorTest.kt similarity index 100% rename from build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ExtensionFunctionGeneratorTest.kt rename to featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ExtensionFunctionGeneratorTest.kt diff --git a/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPluginIntegrationTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPluginIntegrationTest.kt similarity index 100% rename from build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPluginIntegrationTest.kt rename to featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPluginIntegrationTest.kt diff --git a/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPluginTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPluginTest.kt similarity index 100% rename from build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPluginTest.kt rename to featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPluginTest.kt diff --git a/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FlagContainerTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FlagContainerTest.kt similarity index 100% rename from build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FlagContainerTest.kt rename to featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FlagContainerTest.kt diff --git a/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FlagEntryUtilsTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FlagEntryUtilsTest.kt similarity index 100% rename from build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FlagEntryUtilsTest.kt rename to featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FlagEntryUtilsTest.kt diff --git a/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/GenerateIosConstValTaskRegistrationTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/GenerateIosConstValTaskRegistrationTest.kt similarity index 100% rename from build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/GenerateIosConstValTaskRegistrationTest.kt rename to featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/GenerateIosConstValTaskRegistrationTest.kt diff --git a/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/GenerateProguardRulesTaskRegistrationTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/GenerateProguardRulesTaskRegistrationTest.kt similarity index 100% rename from build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/GenerateProguardRulesTaskRegistrationTest.kt rename to featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/GenerateProguardRulesTaskRegistrationTest.kt diff --git a/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/GenerateXcconfigTaskRegistrationTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/GenerateXcconfigTaskRegistrationTest.kt similarity index 100% rename from build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/GenerateXcconfigTaskRegistrationTest.kt rename to featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/GenerateXcconfigTaskRegistrationTest.kt diff --git a/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/IosConstValGeneratorTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/IosConstValGeneratorTest.kt similarity index 100% rename from build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/IosConstValGeneratorTest.kt rename to featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/IosConstValGeneratorTest.kt diff --git a/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/LocalFlagEntryTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/LocalFlagEntryTest.kt similarity index 100% rename from build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/LocalFlagEntryTest.kt rename to featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/LocalFlagEntryTest.kt diff --git a/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ProguardRulesGeneratorTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ProguardRulesGeneratorTest.kt similarity index 100% rename from build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ProguardRulesGeneratorTest.kt rename to featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ProguardRulesGeneratorTest.kt diff --git a/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/XcconfigGeneratorTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/XcconfigGeneratorTest.kt similarity index 100% rename from build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/XcconfigGeneratorTest.kt rename to featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/XcconfigGeneratorTest.kt diff --git a/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationConfigurationTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationConfigurationTest.kt similarity index 100% rename from build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationConfigurationTest.kt rename to featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationConfigurationTest.kt diff --git a/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationDescriptorIntegrityTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationDescriptorIntegrityTest.kt similarity index 100% rename from build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationDescriptorIntegrityTest.kt rename to featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationDescriptorIntegrityTest.kt diff --git a/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationDuplicateKeyTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationDuplicateKeyTest.kt similarity index 100% rename from build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationDuplicateKeyTest.kt rename to featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationDuplicateKeyTest.kt diff --git a/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationIntegrationTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationIntegrationTest.kt similarity index 100% rename from build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationIntegrationTest.kt rename to featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationIntegrationTest.kt diff --git a/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationParseErrorTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationParseErrorTest.kt similarity index 100% rename from build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationParseErrorTest.kt rename to featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationParseErrorTest.kt diff --git a/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GenerateFeaturedRegistryTaskRegistrationTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GenerateFeaturedRegistryTaskRegistrationTest.kt similarity index 100% rename from build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GenerateFeaturedRegistryTaskRegistrationTest.kt rename to featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GenerateFeaturedRegistryTaskRegistrationTest.kt diff --git a/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GeneratedFeaturedRegistryGeneratorTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GeneratedFeaturedRegistryGeneratorTest.kt similarity index 100% rename from build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GeneratedFeaturedRegistryGeneratorTest.kt rename to featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GeneratedFeaturedRegistryGeneratorTest.kt diff --git a/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedKmpPublicationTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedKmpPublicationTest.kt similarity index 100% rename from build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedKmpPublicationTest.kt rename to featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedKmpPublicationTest.kt diff --git a/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestConfigurationTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestConfigurationTest.kt similarity index 100% rename from build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestConfigurationTest.kt rename to featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestConfigurationTest.kt diff --git a/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestEmptyDslTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestEmptyDslTest.kt similarity index 100% rename from build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestEmptyDslTest.kt rename to featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestEmptyDslTest.kt diff --git a/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestIntegrationTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestIntegrationTest.kt similarity index 100% rename from build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestIntegrationTest.kt rename to featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestIntegrationTest.kt diff --git a/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestMappingTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestMappingTest.kt similarity index 100% rename from build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestMappingTest.kt rename to featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestMappingTest.kt diff --git a/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestSerializationTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestSerializationTest.kt similarity index 100% rename from build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestSerializationTest.kt rename to featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestSerializationTest.kt diff --git a/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/GenerateFeaturedManifestTaskRegistrationTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/GenerateFeaturedManifestTaskRegistrationTest.kt similarity index 100% rename from build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/GenerateFeaturedManifestTaskRegistrationTest.kt rename to featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/GenerateFeaturedManifestTaskRegistrationTest.kt diff --git a/build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/TestFixtureSupport.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/TestFixtureSupport.kt similarity index 100% rename from build-logic/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/TestFixtureSupport.kt rename to featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/TestFixtureSupport.kt diff --git a/settings.gradle.kts b/settings.gradle.kts index 0ab3b91..4c5cd99 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -2,7 +2,7 @@ rootProject.name = "Featured" enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") pluginManagement { - includeBuild("build-logic") + includeBuild("featured-gradle-plugin") @Suppress("UnstableApiUsage") repositories { From 4a590181737a8bf8350f991f2aca611aa2c405bb Mon Sep 17 00:00:00 2001 From: Kirill Rozov Date: Sat, 30 May 2026 23:30:21 +0300 Subject: [PATCH 14/15] Prepare v1.0.0 release (#219) * Prepare 1.0.0 release: version, changelog, README stability, fix BCV docs * Fill CHANGELOG gaps (#196 #193 #190) and correct deprecation-policy docs --- CHANGELOG.md | 16 +++++++++++++--- CLAUDE.md | 2 +- CONTRIBUTING.md | 12 +++++------- README.md | 10 ++++++++++ gradle.properties | 2 +- 5 files changed, 30 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da4260b..fbbfb2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [1.0.0] - 2026-05-30 ### Removed @@ -21,6 +21,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `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`. +- The plugin's ProGuard-rules generation task is renamed from `generateProguardRules` to `generateFeaturedProguardRules` to avoid name collisions with other plugins. (#190) +- User documentation moved from the in-repo MkDocs site to the [GitHub Wiki](https://github.com/AndroidBroadcast/Featured/wiki); the `docs/` site and `mkdocs.yml` are removed from the repository. (#193) ### Added @@ -31,13 +33,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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> }` 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` now carries `enumConstants: List?` 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. +- `featured-gradle-plugin` lives at the repo root as an included build; `pluginManagement { includeBuild("featured-gradle-plugin") }` in the root `settings.gradle.kts` exposes it to all main-build subprojects without a version coordinate. ### Fixed +- `ConfigValues.observe()` now wraps provider `Flow` collection in `catch` — exceptions thrown by a local or remote provider are routed to `onProviderError` instead of propagating and breaking the observation flow. (#196) - 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` specializations. +### Platform stability + +- **Android — Stable.** Public API and behavior are covered by SemVer. +- **iOS (SKIE / Swift DCE) — Preview.** Functional, but the Swift-facing API and the SPM packaging may change in minor releases without a major bump. +- **JVM — Preview.** Functional, but the API may change in minor releases without a major bump. + ## [1.0.0-Beta1] - 2026-05-17 ### Added @@ -115,5 +124,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - License mismatch: use MIT in all POM declarations (#174) - Stale artifact IDs in quick-start docs (#179) -[Unreleased]: https://github.com/androidbroadcast/Featured/compare/v1.0.0-Beta1...HEAD +[Unreleased]: https://github.com/androidbroadcast/Featured/compare/v1.0.0...HEAD +[1.0.0]: https://github.com/androidbroadcast/Featured/compare/v1.0.0-Beta1...v1.0.0 [1.0.0-Beta1]: https://github.com/androidbroadcast/Featured/releases/tag/v1.0.0-Beta1 diff --git a/CLAUDE.md b/CLAUDE.md index 1bbdbd0..e787fba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -106,7 +106,7 @@ For non-reactive reads (logging, eager-conditional paths) use `configValues.getV - **Explicit API mode** is on for every KMP module — all public declarations need explicit visibility. Generated flag objects are deliberately `internal`. - **Version catalog** (`gradle/libs.versions.toml`) is the single source of truth for dependency versions. - **Spotless / ktlint** runs over `**/*.kt` and `**/*.kts` excluding `build/`. CI fails on `spotlessCheck`. -- **Binary Compatibility Validator** enforces public-API stability — a public-surface change without `apiDump` update fails CI. Featured has **no migration window** for breaking changes; breaking changes go in directly, the version number reflects it. +- **Public-API stability is reviewed manually in PRs** — there is no automated Binary Compatibility Validator gate (BCV was removed in #150). Reviewers check public-surface changes by hand. Featured has **no migration window** for breaking changes; breaking changes go in directly, the version number reflects it. - **Branching:** `develop` is the integration branch; PRs go to `develop`, not `main`. `main` is updated only on releases. One logical change per PR — do not bundle. - **Comment language:** English (per `.github/copilot-instructions.md`). - **iOS:** SKIE is applied in `:core`; the XCFramework is named `FeaturedCore`. SKIE config is `skie.toml` at repo root. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 08212cf..669b21a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -63,16 +63,14 @@ Featured follows [Semantic Versioning](https://semver.org/) (`MAJOR.MINOR.PATCH` - Changing a function signature in a way that requires call-site updates - Changing the behavior of an existing API in a way that requires migration -Binary Compatibility Validator (BCV) enforces this automatically — a CI check will fail if -a public API surface changes without an explicit `apiDump` update. +Public API changes are reviewed manually during code review — there is no automated Binary Compatibility Validator gate. Reviewers verify that any public-surface change is intentional and that the version bump reflects it. -## Deprecation Policy +## API Stability and Breaking Changes -1. An API is marked `@Deprecated` with a `ReplaceWith` suggestion and a `DeprecationLevel.WARNING`. -2. The deprecated API is kept for **at least one minor release** before being promoted to `ERROR` level. -3. APIs at `ERROR` level are removed in the **next major release**. +Featured has **no deprecation or migration window**. Breaking changes are made directly; the version number reflects the impact per the Versioning table above. -Example timeline: deprecated in `1.2.0` → error in `1.3.0` → removed in `2.0.0`. +- **Android (Stable):** a breaking public-API change (removed/renamed symbol, changed signature) requires a `MAJOR` version bump. +- **iOS (Preview) and JVM (Preview):** public API may change in `MINOR` releases without a major bump; no migration window is provided. ## Releasing a New Version diff --git a/README.md b/README.md index 7147969..229d120 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,16 @@ Featured is a type-safe, reactive feature-flag and configuration management libr - **Multiple providers** — DataStore, SharedPreferences, NSUserDefaults, JavaPreferences, Firebase Remote Config, ConfigCat, or a custom one. - **Debug UI** — a ready-made Compose screen for overriding flags at runtime. +## Platform stability + +| Platform | Status | +|---|---| +| Android | **Stable** | +| iOS (SKIE / DCE) | **Preview** | +| JVM | **Preview** | + +*Preview* means the platform is functional but its public API may change in minor releases without a major version bump. *Stable* platforms follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + ## Quick example ```kotlin diff --git a/gradle.properties b/gradle.properties index c4166f5..8baa471 100644 --- a/gradle.properties +++ b/gradle.properties @@ -36,7 +36,7 @@ android.r8.strictInputValidation=true android.proguard.failOnMissingFiles=true # Publishing -VERSION_NAME=1.0.0-Beta1 +VERSION_NAME=1.0.0 # Dokka org.jetbrains.dokka.experimental.gradle.pluginMode=V2Enabled From 843442ab8c669a98e09432f7e785601298ae6c98 Mon Sep 17 00:00:00 2001 From: Kirill Rozov Date: Sun, 31 May 2026 06:18:40 +0300 Subject: [PATCH 15/15] Port shrinker -keep DCE test (#217) and CodeQL recompile fix (#218) to develop (#221) --- .github/workflows/codeql.yml | 8 +- .../shrinker/assertions/JarAssertions.kt | 89 +++++++++++++++++++ .../r8/R8BooleanFlagEliminationTest.kt | 30 +++++++ .../shrinker/rules/ProguardRulesWriter.kt | 26 ++++++ 4 files changed, 151 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index f560357..2f0b7ee 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -39,9 +39,13 @@ jobs: - uses: github/codeql-action/init@v4 with: languages: java-kotlin + build-mode: manual - name: Build for CodeQL - # autobuild looks for 'testClasses' which doesn't exist in KMP; build manually instead - run: ./gradlew assembleDebug + # autobuild looks for 'testClasses' which doesn't exist in KMP; build manually instead. + # --no-build-cache + --rerun-tasks force Kotlin to actually recompile so the CodeQL + # tracer observes source. Without this, cached/up-to-date compile tasks are skipped and + # CodeQL fails with "no source code seen during build" (exit code 32). + run: ./gradlew assembleDebug --no-build-cache --rerun-tasks - uses: github/codeql-action/analyze@v4 diff --git a/featured-shrinker-tests/src/test/kotlin/dev/androidbroadcast/featured/shrinker/assertions/JarAssertions.kt b/featured-shrinker-tests/src/test/kotlin/dev/androidbroadcast/featured/shrinker/assertions/JarAssertions.kt index 9148399..bd633f4 100644 --- a/featured-shrinker-tests/src/test/kotlin/dev/androidbroadcast/featured/shrinker/assertions/JarAssertions.kt +++ b/featured-shrinker-tests/src/test/kotlin/dev/androidbroadcast/featured/shrinker/assertions/JarAssertions.kt @@ -1,7 +1,13 @@ package dev.androidbroadcast.featured.shrinker.assertions +import org.objectweb.asm.ClassReader +import org.objectweb.asm.ClassVisitor +import org.objectweb.asm.FieldVisitor +import org.objectweb.asm.MethodVisitor +import org.objectweb.asm.Opcodes import java.io.File import java.util.jar.JarFile +import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull @@ -28,3 +34,86 @@ internal fun assertClassPresent( ) } } + +/** + * Asserts that the bytecode of [ownerInternalName] in [jar] contains no reference to + * [referencedInternalName] — neither as a `NEW`/type reference nor as a method-call target. + * + * This proves that R8 actually constant-folded the flag and removed the dead branch's call + * site from the *caller*, as opposed to merely keeping the branch-target class alive. A test + * that only checked class presence would still pass if folding silently stopped working and + * the call site stayed reachable; this assertion closes that gap. + */ +internal fun assertClassDoesNotReference( + jar: File, + ownerInternalName: String, + referencedInternalName: String, +) { + val classBytes = + JarFile(jar).use { jf -> + val entry = + assertNotNull( + jf.getJarEntry("$ownerInternalName.class"), + "Expected $ownerInternalName to be present in the output JAR", + ) + jf.getInputStream(entry).use { it.readBytes() } + } + + var referenced = false + val referencedType = "L$referencedInternalName;" + val detector = + object : ClassVisitor(Opcodes.ASM9) { + override fun visitMethod( + access: Int, + name: String?, + descriptor: String?, + signature: String?, + exceptions: Array?, + ): MethodVisitor = + object : MethodVisitor(Opcodes.ASM9) { + override fun visitTypeInsn( + opcode: Int, + type: String?, + ) { + if (type == referencedInternalName) referenced = true + } + + override fun visitMethodInsn( + opcode: Int, + owner: String?, + name: String?, + descriptor: String?, + isInterface: Boolean, + ) { + if (owner == referencedInternalName) referenced = true + } + + override fun visitFieldInsn( + opcode: Int, + owner: String?, + name: String?, + descriptor: String?, + ) { + if (owner == referencedInternalName) referenced = true + } + } + + override fun visitField( + access: Int, + name: String?, + descriptor: String?, + signature: String?, + value: Any?, + ): FieldVisitor? { + if (descriptor == referencedType) referenced = true + return null + } + } + ClassReader(classBytes).accept(detector, ClassReader.SKIP_DEBUG or ClassReader.SKIP_FRAMES) + + assertFalse( + referenced, + "Expected $ownerInternalName to no longer reference $referencedInternalName after R8 " + + "folded the disabled branch, but a reference was found in its bytecode", + ) +} diff --git a/featured-shrinker-tests/src/test/kotlin/dev/androidbroadcast/featured/shrinker/r8/R8BooleanFlagEliminationTest.kt b/featured-shrinker-tests/src/test/kotlin/dev/androidbroadcast/featured/shrinker/r8/R8BooleanFlagEliminationTest.kt index 3219a4b..337a860 100644 --- a/featured-shrinker-tests/src/test/kotlin/dev/androidbroadcast/featured/shrinker/r8/R8BooleanFlagEliminationTest.kt +++ b/featured-shrinker-tests/src/test/kotlin/dev/androidbroadcast/featured/shrinker/r8/R8BooleanFlagEliminationTest.kt @@ -1,12 +1,14 @@ package dev.androidbroadcast.featured.shrinker.r8 import dev.androidbroadcast.featured.shrinker.assertions.assertClassAbsent +import dev.androidbroadcast.featured.shrinker.assertions.assertClassDoesNotReference import dev.androidbroadcast.featured.shrinker.assertions.assertClassPresent import dev.androidbroadcast.featured.shrinker.bytecode.BIFURCATED_CALLER_INTERNAL import dev.androidbroadcast.featured.shrinker.bytecode.ELSE_BRANCH_CODE_INTERNAL import dev.androidbroadcast.featured.shrinker.bytecode.IF_BRANCH_CODE_INTERNAL import dev.androidbroadcast.featured.shrinker.harness.R8TestHarness import dev.androidbroadcast.featured.shrinker.rules.writeBooleanRules +import dev.androidbroadcast.featured.shrinker.rules.writeBooleanRulesWithKeptDeadBranch import dev.androidbroadcast.featured.shrinker.rules.writeNoBooleanAssumeRules import kotlin.test.Test @@ -94,4 +96,32 @@ internal class R8BooleanFlagEliminationTest : R8TestHarness() { assertClassPresent(outputJar, IF_BRANCH_CODE_INTERNAL) assertClassPresent(outputJar, ELSE_BRANCH_CODE_INTERNAL) } + + /** + * Regression guard for the consumer pitfall: a user-supplied `-keep` on a class that is + * only reachable through the disabled branch defeats dead-code elimination. + * + * The `-assumevalues … return false` rule is present and still works — R8 constant-folds + * the flag and drops the dead branch's call site, so runtime behaviour is unchanged. But + * `-keep class IfBranchCode { *; }` is an unconditional GC root, so the class itself can no + * longer be tree-shaken: it survives in the output even though nothing reaches it. + * + * This is the failure mode behind broad wildcard / `@Keep` rules silently inflating the + * APK. The control test above proves elimination normally happens; this test proves a + * `-keep` is what brings the dead class back. + * + * The final assertion pins the documented split between the two R8 phases: even though the + * class is kept, the `-assumevalues` rule must still have folded the disabled branch, so + * `BifurcatedCaller` must no longer reference `IfBranchCode`. Without this, the test would + * pass even if folding silently stopped working and the call site stayed reachable. + */ + @Test + fun `dead-branch class survives when a user -keep rule pins it despite the assumevalues rule`() { + val outputJar = runBooleanR8 { writeBooleanRulesWithKeptDeadBranch(it) } + + assertClassPresent(outputJar, IF_BRANCH_CODE_INTERNAL) + assertClassPresent(outputJar, ELSE_BRANCH_CODE_INTERNAL) + assertClassPresent(outputJar, BIFURCATED_CALLER_INTERNAL) + assertClassDoesNotReference(outputJar, BIFURCATED_CALLER_INTERNAL, IF_BRANCH_CODE_INTERNAL) + } } diff --git a/featured-shrinker-tests/src/test/kotlin/dev/androidbroadcast/featured/shrinker/rules/ProguardRulesWriter.kt b/featured-shrinker-tests/src/test/kotlin/dev/androidbroadcast/featured/shrinker/rules/ProguardRulesWriter.kt index 69f409d..2bce2b0 100644 --- a/featured-shrinker-tests/src/test/kotlin/dev/androidbroadcast/featured/shrinker/rules/ProguardRulesWriter.kt +++ b/featured-shrinker-tests/src/test/kotlin/dev/androidbroadcast/featured/shrinker/rules/ProguardRulesWriter.kt @@ -49,6 +49,32 @@ internal fun writeBooleanRules( ) } +/** + * Same `-assumevalues` block as [writeBooleanRules] with `returnValue = false`, but with an + * extra `-keep class … { *; }` on the **dead-branch** class ([IF_BRANCH_CODE_FQN]). + * + * This models a consumer that — deliberately or, far more commonly, via a broad wildcard or + * `@Keep` rule — pins a class that is only reachable through a disabled flag branch. + * + * The `-assumevalues` rule still lets R8 constant-fold the flag and drop the dead branch's + * *call site*, so behaviour is unchanged. But `-keep` is an unconditional GC root: the class + * itself can no longer be tree-shaken and survives in the output despite being unreachable, + * silently defeating the size benefit of build-time flags. + */ +internal fun writeBooleanRulesWithKeptDeadBranch(dest: File) { + dest.writeText( + """ + -assumevalues class $BOOL_EXTENSIONS_FQN { + boolean $IS_DARK_MODE_ENABLED($CONFIG_VALUES_FQN) return false; + } + -keep class $BIFURCATED_CALLER_FQN { *; } + -keep class $IF_BRANCH_CODE_FQN { *; } + -keepclassmembers class $ELSE_BRANCH_CODE_FQN { public static int sideEffect; } + -dontwarn ** + """.trimIndent(), + ) +} + /** * No `-assumevalues` block — R8 cannot constant-fold the flag value. * The `-keepclassmembers` rules ensure the `sideEffect` field is not stripped