Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 1 addition & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,7 @@ Two plugins, two roles:

**Enum-flag classpath gotcha.** `featuredAggregation(project(":foo"))` only pulls the manifest variant — not `:foo`'s compile classpath. If `:foo` declares an `enum` flag whose enum type lives in `:foo`, the aggregator module must also declare `implementation(project(":foo"))` so the enum class is visible at compile time. Primitive-only modules need no extra dependency.

**Auto-wiring policy.** The aggregator does **not** auto-wire its output into a source set — the consumer module wires it manually because the plugin can't safely assume KMP vs. AGP vs. plain JVM:

```kotlin
kotlin.sourceSets.getByName("commonMain").kotlin.srcDir(
tasks.named("generateFeaturedRegistry").map { it.outputs.files.singleFile.parentFile }
)
```
**Auto-wiring policy.** Both `generateConfigParam` (from `dev.androidbroadcast.featured`) and `generateFeaturedRegistry` (from the aggregator plugin) auto-wire their generated output directory into the consumer module's compilation — consumers need **zero** manual `srcDir` / `dependsOn`. They write to **distinct** directories so the two outputs never overlap when a module applies both plugins: `generateConfigParam` → `build/generated/featured/commonMain`, `generateFeaturedRegistry` → `build/generated/featured/registry`. The plugin detects the applied Kotlin/Android plugin and wires the right source set: KMP `commonMain` and Kotlin/JVM `main` via a `srcDir(Provider)` (Gradle auto-infers the task dependency); plain AGP via `sourceSets["main"].kotlin.directories.add(<resolved path>)` plus an explicit `dependsOn` on every `compile*Kotlin` / `ksp*` task (the AGP source set rejects `Provider`s at configuration time). The three branches are mutually exclusive in AGP 9, so exactly one fires per module.

## Multi-Module Pattern (canonical, demonstrated in `:sample`)

Expand Down
20 changes: 20 additions & 0 deletions featured-gradle-plugin/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,26 @@ To resolve flags across all modules at once, use Gradle's name-matched task invo
`./gradlew resolveFeatureFlags` — Gradle runs the task in every module that applies the plugin.
The plugin holds no `rootProject` access and is compatible with Gradle Project Isolation.

## Auto-wiring generated sources

`generateConfigParam` (this plugin) and `generateFeaturedRegistry` (the
`dev.androidbroadcast.featured.application` aggregator) auto-wire their generated output into the
consumer module's compilation — consumers write **zero** manual `srcDir` / `dependsOn`. They write to
**distinct** directories so the two outputs never overlap when a module applies both plugins:
`generateConfigParam` → `build/generated/featured/commonMain`, `generateFeaturedRegistry` →
`build/generated/featured/registry`.
The plugin reacts to the applied Kotlin/Android plugin and picks the right source set
(`GeneratedSourceWiring.kt`):

- KMP `org.jetbrains.kotlin.multiplatform` → `commonMain` via `srcDir(Provider)`; Gradle auto-infers the
task dependency. Covers `com.android.kotlin.multiplatform.library` (it co-requires the KMP plugin).
- Kotlin/JVM `org.jetbrains.kotlin.jvm` → `main` via `srcDir(Provider)`.
- Plain AGP `com.android.application` / `com.android.library` → `sourceSets["main"].kotlin.directories.add(<resolved File path>)`
plus an explicit `dependsOn` on every `compile*Kotlin` / `ksp*` task. AGP's `AndroidSourceDirectorySet`
rejects a `Provider` at configuration time, so a resolved path is used and ordering is wired by hand.

The three branches are mutually exclusive in AGP 9, so exactly one fires per module.

## Tests

Tests use Gradle TestKit.
4 changes: 4 additions & 0 deletions featured-gradle-plugin/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,10 @@ tasks.pluginUnderTestMetadata {

dependencies {
compileOnly("com.android.tools.build:gradle:9.1.0")
// Kotlin Gradle plugin DSL (KotlinMultiplatformExtension / KotlinJvmProjectExtension), needed
// at compile time to auto-wire generated sources. compileOnly — the consuming build supplies
// it at runtime when it applies the Kotlin plugin, same pattern as AGP above.
compileOnly("org.jetbrains.kotlin:kotlin-gradle-plugin:${libs.versions.kotlin.get()}")
implementation(libs.kotlinx.serialization.json)
// Inject AGP into the TestKit subprocess via pluginUnderTestMetadata so that the Featured
// plugin can access AndroidComponentsExtension when wireProguardToVariants() is called.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package dev.androidbroadcast.featured.gradle

import dev.androidbroadcast.featured.gradle.aggregation.FEATURED_AGGREGATION_CLASSPATH_CONFIGURATION_NAME
import dev.androidbroadcast.featured.gradle.aggregation.FEATURED_AGGREGATION_CONFIGURATION_NAME
import dev.androidbroadcast.featured.gradle.aggregation.FEATURED_REGISTRY_OBJECT
import dev.androidbroadcast.featured.gradle.aggregation.FEATURED_REGISTRY_PACKAGE
import dev.androidbroadcast.featured.gradle.aggregation.GENERATE_FEATURED_REGISTRY_TASK_NAME
import dev.androidbroadcast.featured.gradle.aggregation.GenerateFeaturedRegistryTask
Expand Down Expand Up @@ -35,14 +34,11 @@ import org.gradle.api.attributes.Usage
* ```
*
* The generated file is written to
* `build/generated/featured/commonMain/GeneratedFeaturedRegistry.kt`.
* Wire the output directory into your source set manually — the plugin does not auto-wire
* to avoid assumptions about whether the consuming module is KMP, AGP, or plain JVM:
* ```kotlin
* kotlin.sourceSets.getByName("commonMain").kotlin.srcDir(
* tasks.named("generateFeaturedRegistry").map { it.outputs.files.singleFile.parentFile }
* )
* ```
* `build/generated/featured/registry/GeneratedFeaturedRegistry.kt` (a dedicated directory, distinct
* from the per-module `generated/featured/commonMain` used by `generateConfigParam`, so the two
* tasks never overlap when a module applies both plugins). The plugin auto-wires that directory into
* the consuming module's compilation (KMP `commonMain`, Kotlin/JVM `main`, or the AGP `main` Kotlin
* source set) — no manual `srcDir` / `dependsOn` is required.
*
* **Enum flag classpath requirement.** A `featuredAggregation(project(":feature:foo"))` dependency
* resolves only the `featured-manifest` Gradle variant — it does NOT put the producer's enum types
Expand Down Expand Up @@ -97,23 +93,52 @@ internal class FeaturedApplicationPlugin : Plugin<Project> {
}
}

target.tasks.register(
GENERATE_FEATURED_REGISTRY_TASK_NAME,
GenerateFeaturedRegistryTask::class.java,
) { task ->
task.group = "featured"
task.description =
"Aggregates featured-manifest.json artifacts and generates GeneratedFeaturedRegistry.kt."
// Lazy artifact view — resolved at execution time, CC-compatible.
task.manifestFiles.from(
classpath.map { it.incoming.artifactView { view -> view.isLenient = false }.files },
)
task.outputPackage.set(FEATURED_REGISTRY_PACKAGE)
task.outputFile.convention(
target.layout.buildDirectory.file(
"generated/featured/commonMain/${FEATURED_REGISTRY_OBJECT}.kt",
),
)
val registryTask =
target.tasks.register(
GENERATE_FEATURED_REGISTRY_TASK_NAME,
GenerateFeaturedRegistryTask::class.java,
) { task ->
task.group = "featured"
task.description =
"Aggregates featured-manifest.json artifacts and generates GeneratedFeaturedRegistry.kt."
// Lazy artifact view — resolved at execution time, CC-compatible.
task.manifestFiles.from(
classpath.map { it.incoming.artifactView { view -> view.isLenient = false }.files },
)
task.outputPackage.set(FEATURED_REGISTRY_PACKAGE)
// Own dedicated directory — NOT the per-module `generated/featured/commonMain` that
// `generateConfigParam` uses. A module that applies both `dev.androidbroadcast.featured`
// and this aggregator would otherwise have two tasks declaring the same @OutputDirectory,
// which Gradle rejects as overlapping outputs.
task.outputDir.convention(
target.layout.buildDirectory.dir("generated/featured/registry"),
)
}

// Auto-wire the generated registry source into compilation. KMP / Kotlin-JVM receive the
// task-carrying @OutputDirectory provider `registryTask.flatMap { it.outputDir }`; passing it
// to `srcDir(Provider)` records the registry task as the dir's builder, so Gradle auto-infers
// the dependency for EVERY consumer (compileAndroidMain, compileKotlinJvm, sourcesJar,
// metadata, …) with no name-matched dependsOn. AGP cannot take a provider (its source set
// rejects one at configuration time), so it gets a resolved File from the pure layout path
// plus the explicit [registryTask] ordering. Only the matching branch fires.
val registryDir = registryTask.flatMap { it.outputDir }
val registryDirFile =
target.layout.buildDirectory
.dir("generated/featured/registry")
.get()
.asFile
target.plugins.withId("org.jetbrains.kotlin.multiplatform") {
wireGeneratedSourcesToKmp(target, registryDir)
}
target.plugins.withId("org.jetbrains.kotlin.jvm") {
wireGeneratedSourcesToKotlinJvm(target, registryDir)
}
target.plugins.withId("com.android.application") {
wireGeneratedSourcesToAndroid(target, registryDirFile, registryTask)
}
target.plugins.withId("com.android.library") {
wireGeneratedSourcesToAndroid(target, registryDirFile, registryTask)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ public class FeaturedPlugin : Plugin<Project> {
val resolveTask = registerResolveFlagsTask(target, extension)

val verifyTask = registerVerifyExpiredFlagsTask(target, extension, resolveTask)
registerConfigParamTask(target, extension, resolveTask, verifyTask)
val configParamTask = registerConfigParamTask(target, extension, resolveTask, verifyTask)
val proguardTask = registerProguardTask(target, extension, resolveTask, verifyTask)
registerIosConstValTask(target, extension, resolveTask, verifyTask)
registerXcconfigTask(target, resolveTask, verifyTask)
Expand All @@ -60,6 +60,36 @@ public class FeaturedPlugin : Plugin<Project> {
target.plugins.withId("com.android.kotlin.multiplatform.library") {
wireProguardToKmpLibraryVariants(target, proguardTask)
}

// Auto-wire the generated ConfigParam sources into compilation so consumer modules need
// zero manual srcDir / dependsOn boilerplate. Only the matching branch fires (the three
// plugin sets are mutually exclusive).
//
// KMP / Kotlin-JVM receive the TASK-CARRYING directory provider
// `configParamTask.flatMap { it.outputDir }` (the task's native @OutputDirectory
// DirectoryProperty). srcDir(Provider) records generateConfigParam as the dir's builder, so
// Gradle auto-infers the dependency for EVERY consumer — compileAndroidMain,
// compileKotlinJvm, jvmSourcesJar, sourcesJar, metadata, lint — without a name-matched
// dependsOn. AGP additionally needs a resolved File because its source set rejects providers
// at configuration time, so it keeps the explicit dependsOn path.
val generatedSrcDir = configParamTask.flatMap { it.outputDir }
val generatedDirFile =
target.layout.buildDirectory
.dir("generated/featured/commonMain")
.get()
.asFile
target.plugins.withId("org.jetbrains.kotlin.multiplatform") {
wireGeneratedSourcesToKmp(target, generatedSrcDir)
}
target.plugins.withId("org.jetbrains.kotlin.jvm") {
wireGeneratedSourcesToKotlinJvm(target, generatedSrcDir)
}
target.plugins.withId("com.android.application") {
wireGeneratedSourcesToAndroid(target, generatedDirFile, configParamTask)
}
target.plugins.withId("com.android.library") {
wireGeneratedSourcesToAndroid(target, generatedDirFile, configParamTask)
}
}

private fun registerResolveFlagsTask(
Expand Down Expand Up @@ -96,7 +126,7 @@ public class FeaturedPlugin : Plugin<Project> {
extension: FeaturedExtension,
resolveTask: TaskProvider<ResolveFlagsTask>,
verifyTask: TaskProvider<VerifyExpiredFlagsTask>,
) {
): TaskProvider<GenerateConfigParamTask> =
target.tasks.register(GENERATE_CONFIG_PARAM_TASK_NAME, GenerateConfigParamTask::class.java) { task ->
task.group = "featured"
task.description =
Expand All @@ -116,7 +146,6 @@ public class FeaturedPlugin : Plugin<Project> {
task.dependsOn(resolveTask)
task.dependsOn(verifyTask)
}
}

private fun registerProguardTask(
target: Project,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,9 @@ import org.gradle.api.tasks.TaskAction
* carried by the `local*` / `remote*` properties; [FeaturedPlugin] resolves the section /
* module-wide fallback chain before wiring them.
*
* All files are written to [outputDir] (`build/generated/featured/commonMain/`).
* Add [outputDir] to the Kotlin compilation source set:
* ```kotlin
* kotlin {
* sourceSets.commonMain.get().kotlin.srcDir(
* tasks.named("generateConfigParam").map { it.outputDir }
* )
* }
* ```
* All files are written to [outputDir] (`build/generated/featured/commonMain/`). [FeaturedPlugin]
* auto-wires this directory into the consumer module's compilation (KMP `commonMain`, Kotlin/JVM
* `main`, or the AGP `main` Kotlin source set) — no manual `srcDir` / `dependsOn` is needed.
*/
@CacheableTask
public abstract class GenerateConfigParamTask : DefaultTask() {
Expand Down
Loading
Loading