From b56649bba7a1e5611489c57747ac68cd73d45081 Mon Sep 17 00:00:00 2001 From: kirich1409 Date: Tue, 19 May 2026 20:57:05 +0300 Subject: [PATCH 1/9] 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 --- .../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 | 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 build-logic/settings.gradle.kts | 51 +++++++++++++++++++ build.gradle.kts | 8 +++ featured-bom/build.gradle.kts | 1 - settings.gradle.kts | 2 +- 85 files changed, 60 insertions(+), 2 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%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/main/kotlin/dev/androidbroadcast/featured/gradle/ConfigParamGenerator.kt (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/main/kotlin/dev/androidbroadcast/featured/gradle/ExtensionFunctionGenerator.kt (100%) 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 (100%) 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 (100%) 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 (100%) 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 (100%) 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 (100%) rename {featured-gradle-plugin => build-logic/featured-gradle-plugin}/src/test/kotlin/dev/androidbroadcast/featured/gradle/ExtensionFunctionGeneratorTest.kt (100%) 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 (100%) 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 (100%) 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 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/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 similarity index 100% rename from featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ConfigParamGenerator.kt rename to build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ConfigParamGenerator.kt diff --git a/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 similarity index 100% rename from featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ExtensionFunctionGenerator.kt rename to build-logic/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ExtensionFunctionGenerator.kt 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 100% 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 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 100% 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 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 100% 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 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 100% 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 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 100% 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 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 100% 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 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 100% 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 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 100% 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 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..66b679a --- /dev/null +++ b/build-logic/settings.gradle.kts @@ -0,0 +1,51 @@ +@file:Suppress("UnstableApiUsage") + +// 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. +val parentProps = java.util.Properties().apply { + rootDir.parentFile.resolve("gradle.properties").inputStream().use { load(it) } +} +gradle.beforeProject { + 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 } +} + +dependencyResolutionManagement { + repositories { + google { + mavenContent { + includeGroupAndSubgroups("androidx") + includeGroupAndSubgroups("com.android") + includeGroupAndSubgroups("com.google") + } + } + mavenCentral() + gradlePluginPortal() + } + versionCatalogs { + create("libs") { + from(files("../gradle/libs.versions.toml")) + } + } +} + +pluginManagement { + repositories { + google { + mavenContent { + includeGroupAndSubgroups("androidx") + includeGroupAndSubgroups("com.android") + includeGroupAndSubgroups("com.google") + } + } + mavenCentral() + gradlePluginPortal() + } +} + +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/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/settings.gradle.kts b/settings.gradle.kts index 1b45cb1..5d4fb82 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,7 +37,6 @@ plugins { id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" } -include(":featured-gradle-plugin") include(":sample:shared") include(":sample:android-app") include(":sample:desktop") From 9d61141cbacf396ef9613818b23b8843f3eaeab6 Mon Sep 17 00:00:00 2001 From: kirich1409 Date: Tue, 19 May 2026 22:04:40 +0300 Subject: [PATCH 2/9] Split sample flags into three KMP feature modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../featured/gradle/ConfigParamGenerator.kt | 16 ++--- .../gradle/ExtensionFunctionGenerator.kt | 57 ++++++++++++----- .../featured/gradle/FlagEntryUtils.kt | 27 +++++++- .../gradle/GenerateConfigParamTask.kt | 12 +++- .../gradle/ConfigParamGeneratorTest.kt | 8 +-- .../gradle/ExtensionFunctionGeneratorTest.kt | 47 ++++++++------ .../featured/gradle/FlagEntryUtilsTest.kt | 32 ++++++++++ sample/feature-checkout/build.gradle.kts | 62 +++++++++++++++++++ .../src/androidMain/AndroidManifest.xml | 2 + .../sample/checkout/CheckoutFlagObservers.kt | 16 +++++ .../sample/checkout/CheckoutVariant.kt | 7 +++ sample/feature-promotions/build.gradle.kts | 54 ++++++++++++++++ .../src/androidMain/AndroidManifest.xml | 2 + .../promotions/PromotionsFlagObservers.kt | 13 ++++ sample/feature-ui/build.gradle.kts | 58 +++++++++++++++++ .../src/androidMain/AndroidManifest.xml | 2 + .../featured/sample/ui/UiFlagObservers.kt | 20 ++++++ settings.gradle.kts | 3 + 18 files changed, 389 insertions(+), 49 deletions(-) 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 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 f698f90..929f3b6 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,18 +1,20 @@ package dev.androidbroadcast.featured.gradle /** - * Generates `GeneratedLocalFlags.kt` and `GeneratedRemoteFlags.kt` β€” internal objects + * 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`: * ```kotlin - * internal object GeneratedLocalFlags { - * val darkMode = ConfigParam("dark_mode", false, category = "UI") + * public object GeneratedLocalFlags { + * public val darkMode = ConfigParam("dark_mode", false, category = "UI") * } * ``` * - * These objects are `internal` β€” consumers access flags exclusively through the - * generated extension functions in [ExtensionFunctionGenerator]. + * 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(GeneratedLocalFlags.x)` without going through the generated extension + * functions in [ExtensionFunctionGenerator]. */ public object ConfigParamGenerator { private const val PACKAGE = "dev.androidbroadcast.featured.generated" @@ -42,9 +44,9 @@ public object ConfigParamGenerator { appendLine() appendLine("import $CONFIG_PARAM_IMPORT") appendLine() - appendLine("internal object $objectName {") + appendLine("public object $objectName {") entries.forEach { entry -> - appendLine(" val ${entry.propertyName} = ${entry.toConfigParamExpression()}") + appendLine(" public 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 cadad7a..44a8e34 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 @@ -1,29 +1,41 @@ package dev.androidbroadcast.featured.gradle /** - * Generates `GeneratedFlagExtensions.kt` β€” public extension functions on `ConfigValues` - * for each declared flag. + * 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 - * fun ConfigValues.isDarkModeEnabled(): Boolean = getValue(GeneratedLocalFlags.darkMode).value + * internal suspend 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 + * 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 - * fun ConfigValues.getPromoBannerEnabled(): ConfigValue = + * internal suspend 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. + * 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" @@ -31,7 +43,26 @@ public object ExtensionFunctionGenerator { private const val CONFIG_VALUE_IMPORT = "dev.androidbroadcast.featured.ConfigValue" /** - * Returns the `@file:JvmName` value for the given Gradle module path. + * 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"`. @@ -39,7 +70,7 @@ public object ExtensionFunctionGenerator { public fun jvmFileName(modulePath: String): String = "Featured${modulePath.modulePathToIdentifier()}_FlagExtensionsKt" /** - * Generates the full source text for `GeneratedFlagExtensions.kt`. + * Generates the full source text for the module-specific `GeneratedFlagExtensions.kt`. * * Returns an empty string if [entries] is empty. */ @@ -48,12 +79,10 @@ public object ExtensionFunctionGenerator { 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() @@ -77,12 +106,12 @@ public object ExtensionFunctionGenerator { 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" + "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()}" - "public fun ConfigValues.$funcName(): ConfigValue<$type> = getValue($objectRef.$propertyName)\n" + "internal suspend fun ConfigValues.$funcName(): ConfigValue<$type> = getValue($objectRef.$propertyName)\n" } } } diff --git a/build-logic/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 index b1f93fe..029ae22 100644 --- a/build-logic/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/build-logic/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 index 624e787..4bbe760 100644 --- a/build-logic/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 @@ -17,7 +17,9 @@ import org.gradle.api.tasks.TaskAction * * - `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. + * - `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,6 +50,10 @@ 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) @@ -60,7 +66,7 @@ public abstract class GenerateConfigParamTask : DefaultTask() { dir.resolve("GeneratedRemoteFlags.kt").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/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 1ea6cb5..4e83e81 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 @@ -46,10 +46,10 @@ class ConfigParamGeneratorTest { } @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") + assertContains(local, "public object GeneratedLocalFlags") } @Test @@ -84,10 +84,10 @@ class ConfigParamGeneratorTest { } @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") + assertContains(remote, "public object GeneratedRemoteFlags") } // ── empty cases ─────────────────────────────────────────────────────────── 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 ad2e938..482e3bc 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 @@ -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,7 +51,7 @@ 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 @@ -55,10 +62,10 @@ class ExtensionFunctionGeneratorTest { } @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,7 +74,7 @@ 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, "suspend fun ConfigValues.getMaxRetries(): Int") assertContains(source, "getValue(GeneratedLocalFlags.maxRetries).value") } @@ -75,7 +82,7 @@ class ExtensionFunctionGeneratorTest { 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,7 +91,7 @@ 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, "suspend fun ConfigValues.getCheckoutVariant(): com.example.CheckoutVariant") assertContains(source, "getValue(GeneratedLocalFlags.checkoutVariant).value") } @@ -101,7 +108,7 @@ 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, "suspend fun ConfigValues.getPromoBanner(): ConfigValue") assertContains(source, "getValue(GeneratedRemoteFlags.promoBanner)") } @@ -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/build-logic/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 index 91f41eb..ff8625f 100644 --- a/build-logic/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/sample/feature-checkout/build.gradle.kts b/sample/feature-checkout/build.gradle.kts new file mode 100644 index 0000000..1c91b3f --- /dev/null +++ b/sample/feature-checkout/build.gradle.kts @@ -0,0 +1,62 @@ +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) + } + } + + listOf( + 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..17edcee --- /dev/null +++ b/sample/feature-checkout/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/checkout/CheckoutFlagObservers.kt @@ -0,0 +1,16 @@ +package dev.androidbroadcast.featured.sample.checkout + +import dev.androidbroadcast.featured.ConfigValues +import dev.androidbroadcast.featured.generated.GeneratedLocalFlags +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +public fun ConfigValues.newCheckoutFlow(): Flow = + observe(GeneratedLocalFlags.newCheckout).map { it.value } + +public fun ConfigValues.checkoutVariantFlow(): Flow = + observe(GeneratedLocalFlags.checkoutVariant).map { it.value } + +public suspend fun ConfigValues.setNewCheckout(value: Boolean) { + override(GeneratedLocalFlags.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..a2ce4e7 --- /dev/null +++ b/sample/feature-promotions/build.gradle.kts @@ -0,0 +1,54 @@ +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) + } + } + + listOf( + 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..5b9cb35 --- /dev/null +++ b/sample/feature-promotions/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/promotions/PromotionsFlagObservers.kt @@ -0,0 +1,13 @@ +package dev.androidbroadcast.featured.sample.promotions + +import dev.androidbroadcast.featured.ConfigValues +import dev.androidbroadcast.featured.generated.GeneratedRemoteFlags +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +public fun ConfigValues.promoBannerEnabledFlow(): Flow = + observe(GeneratedRemoteFlags.promoBannerEnabled).map { it.value } + +public suspend fun ConfigValues.setPromoBannerEnabled(value: Boolean) { + override(GeneratedRemoteFlags.promoBannerEnabled, value) +} diff --git a/sample/feature-ui/build.gradle.kts b/sample/feature-ui/build.gradle.kts new file mode 100644 index 0000000..b05fde6 --- /dev/null +++ b/sample/feature-ui/build.gradle.kts @@ -0,0 +1,58 @@ +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) + } + } + + listOf( + 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..68ce69e --- /dev/null +++ b/sample/feature-ui/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/ui/UiFlagObservers.kt @@ -0,0 +1,20 @@ +package dev.androidbroadcast.featured.sample.ui + +import dev.androidbroadcast.featured.ConfigValues +import dev.androidbroadcast.featured.generated.GeneratedLocalFlags +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +public fun ConfigValues.mainButtonRedFlow(): Flow = + observe(GeneratedLocalFlags.mainButtonRed).map { it.value } + +public suspend fun ConfigValues.setMainButtonRed(value: Boolean) { + override(GeneratedLocalFlags.mainButtonRed, value) +} + +public fun ConfigValues.newFeatureSectionEnabledFlow(): Flow = + observe(GeneratedLocalFlags.newFeatureSectionEnabled).map { it.value } + +public suspend fun ConfigValues.setNewFeatureSectionEnabled(value: Boolean) { + override(GeneratedLocalFlags.newFeatureSectionEnabled, value) +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 5d4fb82..0ab3b91 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -38,6 +38,9 @@ plugins { } 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 ab6b5da611112ae143cb7e24f937196fd00129d3 Mon Sep 17 00:00:00 2001 From: kirich1409 Date: Tue, 19 May 2026 22:09:44 +0300 Subject: [PATCH 3/9] 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 --- sample/shared/build.gradle.kts | 20 ++++ .../featured/SampleFeatureFlags.kt | 94 ------------------- 2 files changed, 20 insertions(+), 94 deletions(-) delete mode 100644 sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleFeatureFlags.kt 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/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, - ) -} From fa9216ac4a0951bb7f942cd7fd8e23f33301cba6 Mon Sep 17 00:00:00 2001 From: kirich1409 Date: Tue, 19 May 2026 22:39:11 +0300 Subject: [PATCH 4/9] Migrate sample consumers to observe-bridges; module-suffix flag objects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../featured/gradle/ConfigParamGenerator.kt | 63 +++++++++++--- .../gradle/ExtensionFunctionGenerator.kt | 15 ++-- .../gradle/GenerateConfigParamTask.kt | 13 +-- .../featured/gradle/LocalFlagEntry.kt | 2 - .../gradle/ConfigParamGeneratorTest.kt | 84 +++++++++++++------ .../gradle/ExtensionFunctionGeneratorTest.kt | 10 +-- build-logic/settings.gradle.kts | 11 ++- .../featured/sample/MainActivity.kt | 6 +- .../sample/checkout/CheckoutFlagObservers.kt | 10 +-- .../promotions/PromotionsFlagObservers.kt | 7 +- .../featured/sample/ui/UiFlagObservers.kt | 11 ++- .../featured/FeaturedSample.kt | 1 + .../featured/SampleViewModel.kt | 64 +++++++++----- 13 files changed, 200 insertions(+), 97 deletions(-) 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 929f3b6..967185d 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,36 +1,77 @@ package dev.androidbroadcast.featured.gradle /** - * Generates `GeneratedLocalFlags.kt` and `GeneratedRemoteFlags.kt` β€” public objects - * containing one typed `ConfigParam` property per declared flag. + * 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`: + * Generated example for a local Boolean flag `dark_mode` in module `:sample:feature-checkout`: * ```kotlin - * public object GeneratedLocalFlags { + * 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(GeneratedLocalFlags.x)` without going through the generated extension - * functions in [ExtensionFunctionGenerator]. + * 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()}" /** - * Generates the Kotlin source for `GeneratedLocalFlags.kt` and - * `GeneratedRemoteFlags.kt` as a pair. + * 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): Pair { + public fun generate(entries: List, modulePath: String): Pair { val (local, remote) = entries.partition { it.isLocal } - return generateObject(local, LocalFlagEntry.GENERATED_LOCAL_OBJECT) to - generateObject(remote, LocalFlagEntry.GENERATED_REMOTE_OBJECT) + return generateObject(local, localObjectName(modulePath)) to + generateObject(remote, remoteObjectName(modulePath)) } private fun generateObject( 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 44a8e34..c9e89f1 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 @@ -80,6 +80,8 @@ public object ExtensionFunctionGenerator { ): 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.") @@ -90,20 +92,23 @@ public object ExtensionFunctionGenerator { if (needsConfigValue) appendLine("import $CONFIG_VALUE_IMPORT") val (localEntries, remoteEntries) = entries.partition { it.isLocal } if (localEntries.isNotEmpty()) { - appendLine("import $PACKAGE.${LocalFlagEntry.GENERATED_LOCAL_OBJECT}") + appendLine("import $PACKAGE.$localObjectName") } if (remoteEntries.isNotEmpty()) { - appendLine("import $PACKAGE.${LocalFlagEntry.GENERATED_REMOTE_OBJECT}") + appendLine("import $PACKAGE.$remoteObjectName") } appendLine() entries.forEach { entry -> - appendLine(entry.toExtensionFunction()) + appendLine(entry.toExtensionFunction(localObjectName, remoteObjectName)) } }.trimEnd() + "\n" } - private fun LocalFlagEntry.toExtensionFunction(): String { - val objectRef = if (isLocal) LocalFlagEntry.GENERATED_LOCAL_OBJECT else LocalFlagEntry.GENERATED_REMOTE_OBJECT + 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" diff --git a/build-logic/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 index 4bbe760..3704eb7 100644 --- a/build-logic/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,8 +15,8 @@ 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. + * - `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. @@ -56,14 +56,15 @@ public abstract class GenerateConfigParamTask : DefaultTask() { 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(ExtensionFunctionGenerator.fileName(modulePath.get())).writeText(extensionsSource) diff --git a/build-logic/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 index a4b072c..7ea04ca 100644 --- a/build-logic/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/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 4e83e81..b7ea5f0 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 @@ -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 public`() { val entries = listOf(localEntry("dark_mode", "false", "Boolean")) - val (local, _) = ConfigParamGenerator.generate(entries) - assertContains(local, "public 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 public`() { val entries = listOf(remoteEntry("promo", "false", "Boolean")) - val (_, remote) = ConfigParamGenerator.generate(entries) - assertContains(remote, "public 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,37 +120,65 @@ 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") } @@ -162,7 +192,7 @@ class ConfigParamGeneratorTest { key = key, defaultValue = default, type = type, - moduleName = ":app", + moduleName = modulePath, propertyName = key.toCamelCase(), flagType = LocalFlagEntry.FLAG_TYPE_LOCAL, ) @@ -175,7 +205,7 @@ class ConfigParamGeneratorTest { key = key, defaultValue = default, type = type, - moduleName = ":app", + moduleName = modulePath, propertyName = key.toCamelCase(), flagType = LocalFlagEntry.FLAG_TYPE_REMOTE, ) 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 482e3bc..82f2d2e 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 @@ -58,7 +58,7 @@ class ExtensionFunctionGeneratorTest { 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 @@ -75,7 +75,7 @@ class ExtensionFunctionGeneratorTest { val entries = listOf(localEntry("max_retries", "Int")) val source = ExtensionFunctionGenerator.generate(entries, modulePath) assertContains(source, "suspend fun ConfigValues.getMaxRetries(): Int") - assertContains(source, "getValue(GeneratedLocalFlags.maxRetries).value") + assertContains(source, "getValue(GeneratedLocalFlagsFeatureCheckout.maxRetries).value") } @Test @@ -92,7 +92,7 @@ class ExtensionFunctionGeneratorTest { 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(GeneratedLocalFlags.checkoutVariant).value") + assertContains(source, "getValue(GeneratedLocalFlagsFeatureCheckout.checkoutVariant).value") } @Test @@ -109,7 +109,7 @@ class ExtensionFunctionGeneratorTest { val entries = listOf(remoteEntry("promo_banner", "Boolean")) val source = ExtensionFunctionGenerator.generate(entries, modulePath) assertContains(source, "suspend fun ConfigValues.getPromoBanner(): ConfigValue") - assertContains(source, "getValue(GeneratedRemoteFlags.promoBanner)") + assertContains(source, "getValue(GeneratedRemoteFlagsFeatureCheckout.promoBanner)") } @Test @@ -117,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", ) } diff --git a/build-logic/settings.gradle.kts b/build-logic/settings.gradle.kts index 66b679a..abed624 100644 --- a/build-logic/settings.gradle.kts +++ b/build-logic/settings.gradle.kts @@ -3,9 +3,14 @@ // 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. -val parentProps = java.util.Properties().apply { - rootDir.parentFile.resolve("gradle.properties").inputStream().use { load(it) } -} + +val parentProps = + java.util.Properties().apply { + rootDir.parentFile + .resolve("gradle.properties") + .inputStream() + .use { load(it) } + } gradle.beforeProject { parentProps.forEach { key, value -> extensions.extraProperties[key.toString()] = value.toString() 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/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/checkout/CheckoutFlagObservers.kt b/sample/feature-checkout/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/checkout/CheckoutFlagObservers.kt index 17edcee..16ca644 100644 --- 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 @@ -1,16 +1,14 @@ package dev.androidbroadcast.featured.sample.checkout import dev.androidbroadcast.featured.ConfigValues -import dev.androidbroadcast.featured.generated.GeneratedLocalFlags +import dev.androidbroadcast.featured.generated.GeneratedLocalFlagsSampleFeatureCheckout import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map -public fun ConfigValues.newCheckoutFlow(): Flow = - observe(GeneratedLocalFlags.newCheckout).map { it.value } +public fun ConfigValues.newCheckoutFlow(): Flow = observe(GeneratedLocalFlagsSampleFeatureCheckout.newCheckout).map { it.value } -public fun ConfigValues.checkoutVariantFlow(): Flow = - observe(GeneratedLocalFlags.checkoutVariant).map { it.value } +public fun ConfigValues.checkoutVariantFlow(): Flow = observe(GeneratedLocalFlagsSampleFeatureCheckout.checkoutVariant).map { it.value } public suspend fun ConfigValues.setNewCheckout(value: Boolean) { - override(GeneratedLocalFlags.newCheckout, value) + override(GeneratedLocalFlagsSampleFeatureCheckout.newCheckout, value) } 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 index 5b9cb35..8178336 100644 --- 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 @@ -1,13 +1,12 @@ package dev.androidbroadcast.featured.sample.promotions import dev.androidbroadcast.featured.ConfigValues -import dev.androidbroadcast.featured.generated.GeneratedRemoteFlags +import dev.androidbroadcast.featured.generated.GeneratedRemoteFlagsSampleFeaturePromotions import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map -public fun ConfigValues.promoBannerEnabledFlow(): Flow = - observe(GeneratedRemoteFlags.promoBannerEnabled).map { it.value } +public fun ConfigValues.promoBannerEnabledFlow(): Flow = observe(GeneratedRemoteFlagsSampleFeaturePromotions.promoBannerEnabled).map { it.value } public suspend fun ConfigValues.setPromoBannerEnabled(value: Boolean) { - override(GeneratedRemoteFlags.promoBannerEnabled, value) + override(GeneratedRemoteFlagsSampleFeaturePromotions.promoBannerEnabled, value) } 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 index 68ce69e..e2a389d 100644 --- 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 @@ -1,20 +1,19 @@ package dev.androidbroadcast.featured.sample.ui import dev.androidbroadcast.featured.ConfigValues -import dev.androidbroadcast.featured.generated.GeneratedLocalFlags +import dev.androidbroadcast.featured.generated.GeneratedLocalFlagsSampleFeatureUi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map -public fun ConfigValues.mainButtonRedFlow(): Flow = - observe(GeneratedLocalFlags.mainButtonRed).map { it.value } +public fun ConfigValues.mainButtonRedFlow(): Flow = observe(GeneratedLocalFlagsSampleFeatureUi.mainButtonRed).map { it.value } public suspend fun ConfigValues.setMainButtonRed(value: Boolean) { - override(GeneratedLocalFlags.mainButtonRed, value) + override(GeneratedLocalFlagsSampleFeatureUi.mainButtonRed, value) } public fun ConfigValues.newFeatureSectionEnabledFlow(): Flow = - observe(GeneratedLocalFlags.newFeatureSectionEnabled).map { it.value } + observe(GeneratedLocalFlagsSampleFeatureUi.newFeatureSectionEnabled).map { it.value } public suspend fun ConfigValues.setNewFeatureSectionEnabled(value: Boolean) { - override(GeneratedLocalFlags.newFeatureSectionEnabled, value) + override(GeneratedLocalFlagsSampleFeatureUi.newFeatureSectionEnabled, value) } 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/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 From 621f81b439d8a845671f2ea0ebcea3a6e325959d Mon Sep 17 00:00:00 2001 From: kirich1409 Date: Tue, 19 May 2026 23:29:49 +0300 Subject: [PATCH 5/9] 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 --- .../featured/gradle/ConfigParamGenerator.kt | 6 +- .../GeneratedFeaturedRegistryGenerator.kt | 3 + .../gradle/ConfigParamGeneratorTest.kt | 14 ++++ .../GeneratedFeaturedRegistryGeneratorTest.kt | 11 +++ .../androidbroadcast/featured/ConfigParam.kt | 11 ++- .../debugui/FeatureFlagsDebugScreen.kt | 73 +++++++++++++++++++ .../sample/checkout/CheckoutFlagObservers.kt | 5 +- .../promotions/PromotionsFlagObservers.kt | 5 +- 8 files changed, 124 insertions(+), 4 deletions(-) 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 967185d..dd7a618 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 @@ -68,7 +68,10 @@ public object ConfigParamGenerator { * 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 { + 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)) @@ -101,6 +104,7 @@ public object ConfigParamGenerator { add("defaultValue = ${formatDefault()}") if (description != null) add("description = \"$description\"") if (category != null) add("category = \"$category\"") + if (isEnum) add("enumConstants = kotlin.enumValues<$type>().toList()") } return "ConfigParam<$typeArg>(${namedArgs.joinToString(", ")})" } diff --git a/build-logic/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 index b23888f..0f65ba7 100644 --- a/build-logic/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.enumValues<${descriptor.enumTypeFqn}>().toList()") + } } // Kotlin accepts trailing commas in listOf() β€” always emit one for uniform diffs. appendLine(" ConfigParam<$typeArg>(${args.joinToString(", ")}),") 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 b7ea5f0..15bc8ad 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 @@ -182,6 +182,20 @@ class ConfigParamGeneratorTest { assertContains(local, "val checkoutVariant = ConfigParam") } + @Test + fun `enum flag emits enumConstants with kotlin enumValues call`() { + val entries = listOf(localEntry("checkout_variant", "LEGACY", "com.example.CheckoutVariant")) + val (local, _) = ConfigParamGenerator.generate(entries, modulePath) + assertContains(local, "enumConstants = kotlin.enumValues().toList()") + } + + @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( diff --git a/build-logic/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 index 5ecaf60..71b4508 100644 --- a/build-logic/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.enumValues().toList()") + } + + @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/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/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/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 index 16ca644..5c92ebe 100644 --- 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 @@ -7,7 +7,10 @@ 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 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-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 index 8178336..093c677 100644 --- 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 @@ -5,7 +5,10 @@ import dev.androidbroadcast.featured.generated.GeneratedRemoteFlagsSampleFeature import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map -public fun ConfigValues.promoBannerEnabledFlow(): Flow = observe(GeneratedRemoteFlagsSampleFeaturePromotions.promoBannerEnabled).map { it.value } +public fun ConfigValues.promoBannerEnabledFlow(): Flow = + observe(GeneratedRemoteFlagsSampleFeaturePromotions.promoBannerEnabled).map { + it.value + } public suspend fun ConfigValues.setPromoBannerEnabled(value: Boolean) { override(GeneratedRemoteFlagsSampleFeaturePromotions.promoBannerEnabled, value) From 6b94acef97c027ecf9666bc037de8f8bf3f24ab7 Mon Sep 17 00:00:00 2001 From: kirich1409 Date: Tue, 19 May 2026 23:37:15 +0300 Subject: [PATCH 6/9] 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 --- CHANGELOG.md | 12 +++++++++++- sample/CLAUDE.md | 29 +++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 sample/CLAUDE.md 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/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`. From 08a787de4eef05a61d9281a7a15d3a834de2a3ca Mon Sep 17 00:00:00 2001 From: kirich1409 Date: Tue, 19 May 2026 23:48:24 +0300 Subject: [PATCH 7/9] 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 --- sample/feature-checkout/build.gradle.kts | 8 +++----- sample/feature-promotions/build.gradle.kts | 8 +++----- sample/feature-ui/build.gradle.kts | 8 +++----- 3 files changed, 9 insertions(+), 15 deletions(-) diff --git a/sample/feature-checkout/build.gradle.kts b/sample/feature-checkout/build.gradle.kts index 1c91b3f..95a9bcc 100644 --- a/sample/feature-checkout/build.gradle.kts +++ b/sample/feature-checkout/build.gradle.kts @@ -24,11 +24,9 @@ kotlin { } } - listOf( - iosX64(), - iosArm64(), - iosSimulatorArm64(), - ) + iosX64() + iosArm64() + iosSimulatorArm64() jvm() diff --git a/sample/feature-promotions/build.gradle.kts b/sample/feature-promotions/build.gradle.kts index a2ce4e7..fc23e29 100644 --- a/sample/feature-promotions/build.gradle.kts +++ b/sample/feature-promotions/build.gradle.kts @@ -24,11 +24,9 @@ kotlin { } } - listOf( - iosX64(), - iosArm64(), - iosSimulatorArm64(), - ) + iosX64() + iosArm64() + iosSimulatorArm64() jvm() diff --git a/sample/feature-ui/build.gradle.kts b/sample/feature-ui/build.gradle.kts index b05fde6..1bbbe9f 100644 --- a/sample/feature-ui/build.gradle.kts +++ b/sample/feature-ui/build.gradle.kts @@ -24,11 +24,9 @@ kotlin { } } - listOf( - iosX64(), - iosArm64(), - iosSimulatorArm64(), - ) + iosX64() + iosArm64() + iosSimulatorArm64() jvm() From f8bc68da7dfe5af10e61f1120b234d212b480343 Mon Sep 17 00:00:00 2001 From: kirich1409 Date: Wed, 20 May 2026 08:18:05 +0300 Subject: [PATCH 8/9] Track parent gradle.properties via providers.fileContents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- build-logic/settings.gradle.kts | 55 ++++++++++--------- .../featured/ConfigParamTest.kt | 55 +++++++++++++++++++ 2 files changed, 85 insertions(+), 25 deletions(-) diff --git a/build-logic/settings.gradle.kts b/build-logic/settings.gradle.kts index abed624..201ab5a 100644 --- a/build-logic/settings.gradle.kts +++ b/build-logic/settings.gradle.kts @@ -1,25 +1,8 @@ @file:Suppress("UnstableApiUsage") -// 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. - -val parentProps = - java.util.Properties().apply { - rootDir.parentFile - .resolve("gradle.properties") - .inputStream() - .use { load(it) } - } -gradle.beforeProject { - 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 } -} +// pluginManagement must be the first block per Gradle's settings-script rules. -dependencyResolutionManagement { +pluginManagement { repositories { google { mavenContent { @@ -31,14 +14,9 @@ dependencyResolutionManagement { mavenCentral() gradlePluginPortal() } - versionCatalogs { - create("libs") { - from(files("../gradle/libs.versions.toml")) - } - } } -pluginManagement { +dependencyResolutionManagement { repositories { google { mavenContent { @@ -50,6 +28,33 @@ pluginManagement { 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" 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=")) + } } From b26ba011b58055109879b5a8a6dbd53b89b82217 Mon Sep 17 00:00:00 2001 From: kirich1409 Date: Wed, 20 May 2026 09:54:22 +0300 Subject: [PATCH 9/9] Address review: switch generators to kotlin.enums.enumEntries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../androidbroadcast/featured/gradle/ConfigParamGenerator.kt | 2 +- .../gradle/aggregation/GeneratedFeaturedRegistryGenerator.kt | 2 +- .../featured/gradle/ConfigParamGeneratorTest.kt | 4 ++-- .../aggregation/GeneratedFeaturedRegistryGeneratorTest.kt | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) 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 dd7a618..9853328 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 @@ -104,7 +104,7 @@ public object ConfigParamGenerator { add("defaultValue = ${formatDefault()}") if (description != null) add("description = \"$description\"") if (category != null) add("category = \"$category\"") - if (isEnum) add("enumConstants = kotlin.enumValues<$type>().toList()") + if (isEnum) add("enumConstants = kotlin.enums.enumEntries<$type>()") } return "ConfigParam<$typeArg>(${namedArgs.joinToString(", ")})" } diff --git a/build-logic/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 index 0f65ba7..554ebe9 100644 --- a/build-logic/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 @@ -60,7 +60,7 @@ internal object GeneratedFeaturedRegistryGenerator { 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.enumValues<${descriptor.enumTypeFqn}>().toList()") + add("enumConstants = kotlin.enums.enumEntries<${descriptor.enumTypeFqn}>()") } } // Kotlin accepts trailing commas in listOf() β€” always emit one for uniform diffs. 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 15bc8ad..7ca7cb7 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 @@ -183,10 +183,10 @@ class ConfigParamGeneratorTest { } @Test - fun `enum flag emits enumConstants with kotlin enumValues call`() { + 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.enumValues().toList()") + assertContains(local, "enumConstants = kotlin.enums.enumEntries()") } @Test diff --git a/build-logic/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 index 71b4508..bd73c34 100644 --- a/build-logic/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,7 +146,7 @@ class GeneratedFeaturedRegistryGeneratorTest { ) assertContains(source, "ConfigParam") assertContains(source, "defaultValue = com.example.CheckoutVariant.LEGACY") - assertContains(source, "enumConstants = kotlin.enumValues().toList()") + assertContains(source, "enumConstants = kotlin.enums.enumEntries()") } @Test