feat(gradle-plugin): generate ProGuard/R8 -assumevalues rules for Android/JVM#80
Conversation
…calflag flags - Add ProguardRulesGenerator object that emits -assumevalues blocks only for Boolean @localflag entries with defaultValue=false, enabling R8 dead-branch elimination for disabled feature flags - Add GenerateProguardRulesTask (reads ScanLocalFlagsTask output, writes build/featured/proguard-featured.pro) - Wire generateProguardRules task into FeaturedPlugin; task depends on scanLocalFlags and runs within the 'featured' group - 14 new tests covering generator logic and task registration Closes #16 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Review Summary by QodoGenerate ProGuard/R8 -assumevalues rules for disabled @localflag flags
WalkthroughsDescription• Add ProGuard/R8 rule generation for disabled feature flags • Implement ProguardRulesGenerator to emit -assumevalues blocks • Create GenerateProguardRulesTask Gradle task for rule file generation • Wire task into plugin with dependency on scanLocalFlags task • Add 14 comprehensive tests for generator and task registration Diagramflowchart LR
A["ScanLocalFlagsTask<br/>output file"] -->|pipe-delimited<br/>flag entries| B["GenerateProguardRulesTask"]
B -->|parse entries| C["ProguardRulesGenerator"]
C -->|filter Boolean<br/>defaultValue=false| D["proguard-featured.pro<br/>-assumevalues block"]
E["FeaturedPlugin"] -->|registers| B
B -->|depends on| A
File Changes1. featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPlugin.kt
|
Code Review by Qodo
1. generateProguardRules not release-wired
|
There was a problem hiding this comment.
Pull request overview
Adds ProGuard/R8 rules generation support to the featured-gradle-plugin, deriving output from existing @LocalFlag source scanning so Android/JVM builds can (eventually) benefit from R8 dead-branch elimination.
Changes:
- Introduces
ProguardRulesGeneratorto produce a-assumevaluesrules block from scanned@LocalFlagentries. - Adds
GenerateProguardRulesTaskto read the scan report and writebuild/featured/proguard-featured.pro. - Wires
generateProguardRulesintoFeaturedPluginand adds unit tests for generator logic and task registration/dependencies.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ProguardRulesGenerator.kt | New generator for ProGuard/R8 -assumevalues rules based on scanned local flags. |
| featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateProguardRulesTask.kt | New Gradle task to parse scan output and write the generated ProGuard rules file. |
| featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPlugin.kt | Registers the new generateProguardRules task and wires it to depend on scanning. |
| featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ProguardRulesGeneratorTest.kt | Adds generator-focused unit tests. |
| featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/GenerateProguardRulesTaskRegistrationTest.kt | Adds plugin/task registration and dependency wiring tests. |
| * Generates ProGuard/R8 `-assumevalues` rules for `@LocalFlag`-annotated | ||
| * `ConfigParam` properties whose `defaultValue` is `false`. | ||
| * | ||
| * Rules are emitted only for Boolean flags frozen to `false` at release build | ||
| * time, enabling R8 dead-branch elimination for code guarded by those flags. | ||
| * Flags with `defaultValue = true`, non-boolean flags, and `@RemoteFlag` | ||
| * declarations are intentionally excluded. | ||
| * | ||
| * **Method signature note:** The generated rule targets `isEnabled(String)` | ||
| * on `ConfigValues`. This signature will be finalised when the `isEnabled` | ||
| * API lands; update [IS_ENABLED_RULE] if the signature changes. | ||
| */ | ||
| public object ProguardRulesGenerator { | ||
| private const val CONFIG_VALUES_CLASS = "dev.androidbroadcast.featured.ConfigValues" | ||
|
|
||
| /** | ||
| * ProGuard member rule that tells R8 `isEnabled(String)` always returns `false`. | ||
| * A single copy covers all disabled flags — R8 applies it per call-site key. | ||
| * | ||
| * Update this constant when the `isEnabled` method signature is finalised. | ||
| */ | ||
| private const val IS_ENABLED_RULE = "boolean isEnabled(java.lang.String) return false;" | ||
|
|
||
| /** | ||
| * Generates a ProGuard rules string from [entries]. | ||
| * | ||
| * Returns a blank string when no qualifying entries exist (Boolean type, | ||
| * `defaultValue == "false"`). Otherwise returns a single `-assumevalues` | ||
| * block listing the disabled flag keys as comments and one shared method rule. | ||
| */ | ||
| public fun generate(entries: List<LocalFlagEntry>): String { | ||
| val disabled = entries.filter { it.type == "Boolean" && it.defaultValue == "false" } | ||
| if (disabled.isEmpty()) return "" | ||
|
|
||
| return buildString { | ||
| appendLine("-assumevalues class $CONFIG_VALUES_CLASS {") | ||
| appendLine(" # Flags frozen at release build time — default value is false") | ||
| disabled.forEach { entry -> | ||
| appendLine(" # key: ${entry.key} (module: ${entry.moduleName})") | ||
| } | ||
| appendLine(" $IS_ENABLED_RULE") | ||
| append("}") | ||
| } |
There was a problem hiding this comment.
IS_ENABLED_RULE tells R8/ProGuard that ConfigValues.isEnabled(String) always returns false, which would make all feature checks constant-false (including keys with defaultValue=true and any remote flags) and can change app behavior. Also, there is currently no isEnabled(...) method on dev.androidbroadcast.featured.ConfigValues in this repo, so including this rule is likely to produce R8/ProGuard missing-member warnings or build failures when the generated file is consumed. Consider removing the method rule entirely until the API exists and can be targeted safely (e.g., via per-flag methods/fields rather than a key-based String API), or gate rule generation on the presence of that API.
| * Generates ProGuard/R8 `-assumevalues` rules for `@LocalFlag`-annotated | |
| * `ConfigParam` properties whose `defaultValue` is `false`. | |
| * | |
| * Rules are emitted only for Boolean flags frozen to `false` at release build | |
| * time, enabling R8 dead-branch elimination for code guarded by those flags. | |
| * Flags with `defaultValue = true`, non-boolean flags, and `@RemoteFlag` | |
| * declarations are intentionally excluded. | |
| * | |
| * **Method signature note:** The generated rule targets `isEnabled(String)` | |
| * on `ConfigValues`. This signature will be finalised when the `isEnabled` | |
| * API lands; update [IS_ENABLED_RULE] if the signature changes. | |
| */ | |
| public object ProguardRulesGenerator { | |
| private const val CONFIG_VALUES_CLASS = "dev.androidbroadcast.featured.ConfigValues" | |
| /** | |
| * ProGuard member rule that tells R8 `isEnabled(String)` always returns `false`. | |
| * A single copy covers all disabled flags — R8 applies it per call-site key. | |
| * | |
| * Update this constant when the `isEnabled` method signature is finalised. | |
| */ | |
| private const val IS_ENABLED_RULE = "boolean isEnabled(java.lang.String) return false;" | |
| /** | |
| * Generates a ProGuard rules string from [entries]. | |
| * | |
| * Returns a blank string when no qualifying entries exist (Boolean type, | |
| * `defaultValue == "false"`). Otherwise returns a single `-assumevalues` | |
| * block listing the disabled flag keys as comments and one shared method rule. | |
| */ | |
| public fun generate(entries: List<LocalFlagEntry>): String { | |
| val disabled = entries.filter { it.type == "Boolean" && it.defaultValue == "false" } | |
| if (disabled.isEmpty()) return "" | |
| return buildString { | |
| appendLine("-assumevalues class $CONFIG_VALUES_CLASS {") | |
| appendLine(" # Flags frozen at release build time — default value is false") | |
| disabled.forEach { entry -> | |
| appendLine(" # key: ${entry.key} (module: ${entry.moduleName})") | |
| } | |
| appendLine(" $IS_ENABLED_RULE") | |
| append("}") | |
| } | |
| * Placeholder for generating ProGuard/R8 rules for `@LocalFlag`-annotated | |
| * `ConfigParam` properties. | |
| * | |
| * The generation of `-assumevalues` rules for `ConfigValues.isEnabled(...)` | |
| * is intentionally disabled for now because there is no stable | |
| * `isEnabled` API to target safely. Emitting such a rule prematurely could | |
| * change runtime behaviour (for example, by forcing all flags to `false`) | |
| * or cause R8/ProGuard to report missing-member warnings. | |
| * | |
| * When the `isEnabled` API is available and its signature is finalised, | |
| * this generator can be updated to emit precise, per-flag rules. | |
| */ | |
| public object ProguardRulesGenerator { | |
| /** | |
| * Generates a ProGuard rules string from [entries]. | |
| * | |
| * Currently returns a blank string because there is no stable | |
| * `ConfigValues.isEnabled(...)` API to target safely. | |
| */ | |
| public fun generate(entries: List<LocalFlagEntry>): String { | |
| // Intentionally no-op until the isEnabled API exists and can be | |
| // targeted without changing runtime behaviour unexpectedly. | |
| return "" |
| return file | ||
| .readLines() | ||
| .filter { it.isNotBlank() } | ||
| .mapNotNull { line -> | ||
| val parts = line.split("|") | ||
| if (parts.size != 4) return@mapNotNull null | ||
| LocalFlagEntry( | ||
| key = parts[0], | ||
| defaultValue = parts[1], | ||
| type = parts[2], | ||
| moduleName = parts[3], | ||
| ) | ||
| } |
There was a problem hiding this comment.
parseScanResult() silently drops any line that doesn't split into exactly 4 parts. Because the format is a raw pipe-delimited string, a key/defaultValue/moduleName containing | (or any accidental extra delimiter) will cause the entry to be ignored without any visibility, potentially skipping rule generation. Consider switching the scan output to a robust encoding (e.g., JSON Lines) or at minimum logging a warning/failing the task when malformed lines are encountered so regressions aren’t hidden.
| return file | |
| .readLines() | |
| .filter { it.isNotBlank() } | |
| .mapNotNull { line -> | |
| val parts = line.split("|") | |
| if (parts.size != 4) return@mapNotNull null | |
| LocalFlagEntry( | |
| key = parts[0], | |
| defaultValue = parts[1], | |
| type = parts[2], | |
| moduleName = parts[3], | |
| ) | |
| } | |
| val entries = mutableListOf<LocalFlagEntry>() | |
| var malformedCount = 0 | |
| file.readLines().forEachIndexed { index, rawLine -> | |
| val line = rawLine.trim() | |
| if (line.isEmpty()) { | |
| return@forEachIndexed | |
| } | |
| // Use a bounded split so that any additional '|' characters after the third | |
| // delimiter are included in the last field instead of creating extra parts. | |
| val parts = line.split("|", limit = 4) | |
| if (parts.size != 4) { | |
| malformedCount++ | |
| logger.warn( | |
| "[featured] Malformed scan result line ${index + 1} in ${file.path}: " + | |
| "expected 4 '|' separated fields but found ${parts.size}. " + | |
| "Line content: `$line`" | |
| ) | |
| return@forEachIndexed | |
| } | |
| entries += LocalFlagEntry( | |
| key = parts[0], | |
| defaultValue = parts[1], | |
| type = parts[2], | |
| moduleName = parts[3], | |
| ) | |
| } | |
| if (malformedCount > 0) { | |
| logger.warn( | |
| "[featured] Skipped $malformedCount malformed line(s) in scan result file ${file.path}; " + | |
| "generated ProGuard rules may be incomplete." | |
| ) | |
| } | |
| return entries |
| private fun registerProguardGenerationTask( | ||
| target: Project, | ||
| scanTask: TaskProvider<ScanLocalFlagsTask>, | ||
| ) { | ||
| target.tasks.register(GENERATE_PROGUARD_TASK_NAME, GenerateProguardRulesTask::class.java) { task -> | ||
| task.group = "featured" | ||
| task.description = | ||
| "Generates ProGuard/R8 -assumevalues rules for @LocalFlag(defaultValue=false) flags in '${target.path}'." | ||
| task.scanResultFile.set(scanTask.flatMap { it.outputFile }) | ||
| task.outputFile.set( | ||
| target.layout.buildDirectory.file("featured/proguard-featured.pro"), | ||
| ) | ||
| task.dependsOn(scanTask) | ||
| } |
There was a problem hiding this comment.
The PR description indicates this change “Closes #16”, but the implementation here only generates the ProGuard file via a task; it does not auto-wire the generated proguard-featured.pro into an Android module’s proguardFiles nor add an integration test validating R8 dead-code elimination (both called out in #16 acceptance criteria). Consider updating the PR/issue linkage (don’t close #16 yet) or extending the plugin to apply the generated file automatically for Android release builds and adding an integration test.
| public class FeaturedPlugin : Plugin<Project> { | ||
| override fun apply(target: Project) { | ||
| val scanTask = registerModuleScanTask(target) | ||
| registerProguardGenerationTask(target, scanTask) | ||
| wireModuleTaskToRootAggregator(target, scanTask) | ||
| } | ||
|
|
There was a problem hiding this comment.
1. generateproguardrules not release-wired 📎 Requirement gap ✓ Correctness
The plugin registers generateProguardRules but does not wire it into any Android release/minify task, so the rules file is not generated as part of release builds. This fails the requirement that rule generation happens during the release build flow.
Agent Prompt
## Issue description
`generateProguardRules` is registered but not executed as part of release builds, so the rules file may not exist when R8 runs.
## Issue Context
Compliance requires the rules file to be generated during the release build flow.
## Fix Focus Areas
- featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPlugin.kt[20-56]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| /** | ||
| * ProGuard member rule that tells R8 `isEnabled(String)` always returns `false`. | ||
| * A single copy covers all disabled flags — R8 applies it per call-site key. | ||
| * | ||
| * Update this constant when the `isEnabled` method signature is finalised. | ||
| */ | ||
| private const val IS_ENABLED_RULE = "boolean isEnabled(java.lang.String) return false;" | ||
|
|
||
| /** | ||
| * Generates a ProGuard rules string from [entries]. | ||
| * | ||
| * Returns a blank string when no qualifying entries exist (Boolean type, | ||
| * `defaultValue == "false"`). Otherwise returns a single `-assumevalues` | ||
| * block listing the disabled flag keys as comments and one shared method rule. | ||
| */ | ||
| public fun generate(entries: List<LocalFlagEntry>): String { | ||
| val disabled = entries.filter { it.type == "Boolean" && it.defaultValue == "false" } | ||
| if (disabled.isEmpty()) return "" | ||
|
|
||
| return buildString { | ||
| appendLine("-assumevalues class $CONFIG_VALUES_CLASS {") | ||
| appendLine(" # Flags frozen at release build time — default value is false") | ||
| disabled.forEach { entry -> | ||
| appendLine(" # key: ${entry.key} (module: ${entry.moduleName})") | ||
| } | ||
| appendLine(" $IS_ENABLED_RULE") | ||
| append("}") |
There was a problem hiding this comment.
2. isenabled() assumevalues disables all 📎 Requirement gap ✓ Correctness
The generated -assumevalues rule forces ConfigValues.isEnabled(String) to always return false, which is not limited to only @LocalFlag defaultValue=false flags and would effectively freeze all flags to disabled. This violates the requirement to apply assumevalues only to qualifying @LocalFlag defaultValue=false params.
Agent Prompt
## Issue description
The generator emits an unconditional assumevalues rule for `isEnabled(String)` returning `false`, which is not restricted to only `@LocalFlag` entries with `defaultValue=false`.
## Issue Context
Compliance requires the generated rules to apply to all and only qualifying `@LocalFlag defaultValue=false` flags.
## Fix Focus Areas
- featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ProguardRulesGenerator.kt[19-45]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| * Wire the generated file into your Android module's ProGuard configuration: | ||
| * ```kotlin | ||
| * android { | ||
| * buildTypes { | ||
| * release { | ||
| * proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), | ||
| * "proguard-featured.pro") | ||
| * } | ||
| * } | ||
| * } | ||
| * ``` |
There was a problem hiding this comment.
3. proguard-featured.pro requires manual setup 📎 Requirement gap ✓ Correctness
The implementation does not automatically add the generated rules file to the Android module’s ProGuard/R8 configuration, and instead documents a manual proguardFiles(...) step. This violates the requirement that the rules file be applied automatically during release minification.
Agent Prompt
## Issue description
The generated ProGuard rules file is not automatically applied to Android release minification; developers are instructed to manually configure `proguardFiles`.
## Issue Context
Compliance requires zero manual steps: the Android module must consume the generated rules during release builds.
## Fix Focus Areas
- featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPlugin.kt[20-56]
- featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateProguardRulesTask.kt[19-29]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| class ProguardRulesGeneratorTest { | ||
| @Test | ||
| fun `generates no rules when entries list is empty`() { | ||
| val rules = ProguardRulesGenerator.generate(emptyList()) | ||
| assertTrue( | ||
| rules.isBlank(), | ||
| "Expected blank output for empty entries, got: '$rules'", | ||
| ) | ||
| } | ||
|
|
||
| @Test | ||
| fun `generates no rules for boolean flag with defaultValue true`() { | ||
| val entries = | ||
| listOf( | ||
| LocalFlagEntry(key = "feature_enabled", defaultValue = "true", type = "Boolean", moduleName = ":app"), | ||
| ) | ||
| val rules = ProguardRulesGenerator.generate(entries) | ||
| assertTrue( | ||
| rules.isBlank(), | ||
| "Expected no rules for flags with defaultValue=true, got: '$rules'", | ||
| ) | ||
| } | ||
|
|
||
| @Test | ||
| fun `generates no rules for non-boolean flags`() { | ||
| val entries = | ||
| listOf( | ||
| LocalFlagEntry(key = "timeout", defaultValue = "30", type = "Int", moduleName = ":app"), | ||
| LocalFlagEntry(key = "server_url", defaultValue = "https://example.com", type = "String", moduleName = ":app"), | ||
| ) | ||
| val rules = ProguardRulesGenerator.generate(entries) | ||
| assertTrue( | ||
| rules.isBlank(), | ||
| "Expected no rules for non-boolean flags, got: '$rules'", | ||
| ) | ||
| } | ||
|
|
||
| @Test | ||
| fun `generates assumevalues rule for boolean flag with defaultValue false`() { | ||
| val entries = | ||
| listOf( | ||
| LocalFlagEntry(key = "dark_mode", defaultValue = "false", type = "Boolean", moduleName = ":app"), | ||
| ) | ||
| val rules = ProguardRulesGenerator.generate(entries) | ||
| assertContains(rules, "-assumevalues") | ||
| assertContains(rules, "dark_mode") | ||
| assertFalse(rules.isBlank()) | ||
| } |
There was a problem hiding this comment.
4. Missing r8 integration test 📎 Requirement gap ⛯ Reliability
The PR adds only unit tests for rule generation and task registration, but no end-to-end integration test that builds a minified/R8 variant and asserts dead code elimination from the generated rules. This fails the requirements to verify R8 elimination behavior and to provide an automated integration test covering generation + application + elimination.
Agent Prompt
## Issue description
There is no automated integration test that executes a minified/R8 build and asserts that code guarded by a qualifying `@LocalFlag defaultValue=false` is eliminated due to the generated rules.
## Issue Context
Compliance requires verifying the full pipeline: rules generation + rules application in release minification + observable dead code elimination.
## Fix Focus Areas
- featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ProguardRulesGeneratorTest.kt[9-56]
- featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/GenerateProguardRulesTaskRegistrationTest.kt[8-73]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| private const val CONFIG_VALUES_CLASS = "dev.androidbroadcast.featured.ConfigValues" | ||
|
|
||
| /** | ||
| * ProGuard member rule that tells R8 `isEnabled(String)` always returns `false`. | ||
| * A single copy covers all disabled flags — R8 applies it per call-site key. | ||
| * | ||
| * Update this constant when the `isEnabled` method signature is finalised. | ||
| */ | ||
| private const val IS_ENABLED_RULE = "boolean isEnabled(java.lang.String) return false;" | ||
|
|
There was a problem hiding this comment.
5. Missing isenabled target 🐞 Bug ✓ Correctness
ProguardRulesGenerator emits an -assumevalues member rule for dev.androidbroadcast.featured.ConfigValues.isEnabled(String), but ConfigValues currently has no such method, so including the generated file can produce R8/ProGuard warnings or shrink-step failures for unknown members.
Agent Prompt
### Issue description
`ProguardRulesGenerator` generates an `-assumevalues` rule for `ConfigValues.isEnabled(String)`, but `ConfigValues` currently does not define `isEnabled`, so the generated ProGuard file refers to an unknown member.
### Issue Context
This can surface as R8/ProGuard warnings or failures when consumers include `build/featured/proguard-featured.pro` in their shrink configuration.
### Fix Focus Areas
- featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ProguardRulesGenerator.kt[17-26]
- core/src/commonMain/kotlin/dev/androidbroadcast/featured/ConfigValues.kt[17-65]
### What to change
- Either implement the exact `isEnabled(java.lang.String): boolean` API on `ConfigValues` (JVM/Android-visible signature), **or** stop emitting this member rule until the API exists.
- If the API will differ, make the signature configurable (extension/property) so the generator doesn’t hardcode a potentially invalid rule.
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
Summary
ProguardRulesGenerator— pure logic object that generates a single-assumevaluesblock forConfigValues, listing only@LocalFlagBoolean flags withdefaultValue = falseas comments, plus one sharedisEnabled(String) return falsemember rule enabling R8 dead-branch eliminationGenerateProguardRulesTask— Gradle task that readsScanLocalFlagsTask's pipe-delimited output file and writesbuild/featured/proguard-featured.progenerateProguardRulestask intoFeaturedPlugin; it depends onscanLocalFlagsand runs in thefeaturedgroup@RemoteFlag, unannotated flags,defaultValue = true, or non-Boolean typesTest plan
./gradlew :featured-gradle-plugin:test— 35 tests, 0 failures./gradlew :core:koverVerify— core coverage ≥ 90%./gradlew spotlessCheck— formatting clean./gradlew test :core:koverVerify spotlessCheck— BUILD SUCCESSFULCloses #16
🤖 Generated with Claude Code