From 4629c67be9b01c8b058289779717bf259dec5e7c Mon Sep 17 00:00:00 2001 From: kirich1409 Date: Fri, 3 Apr 2026 11:03:42 +0300 Subject: [PATCH 1/4] fix(ci): remove docs build from pull_request trigger Docs are now built and published only on push to main or release tags. Running a full Dokka + MkDocs build on every PR is slow and its failure should not block merging. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/docs.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 5816b8d..b0a5308 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -7,9 +7,6 @@ on: tags: - "v[0-9]+.[0-9]+.[0-9]+" - "v[0-9]+.[0-9]+.[0-9]+-*" - pull_request: - branches: - - main permissions: contents: write From 60cbfe367ba79b764ee54362aea740f89a2f90ec Mon Sep 17 00:00:00 2001 From: kirich1409 Date: Fri, 3 Apr 2026 12:05:54 +0300 Subject: [PATCH 2/4] test(gradle-plugin): expand R8 elimination coverage to full boolean matrix and int flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace single-branch caller with a bifurcated caller (if + else) so one input JAR covers the full 2×2 boolean matrix: · flag=false → if-branch eliminated, else-branch present · flag=true → else-branch eliminated, if-branch present · no assumevalues → both branches survive (baseline) - Add Int flag test family: asserts that -assumevalues return 0 causes R8 to constant-fold `0 > 0` to false and eliminate the guarded class - Extract sideEffectClassBytes(name) helper to deduplicate branch-target bytecode - Add -keepclassmembers for the surviving branch's sideEffect field to prevent R8 from eliminating it via write-only field optimisation (dead class stays unprotected so R8 can eliminate it freely) Co-Authored-By: Claude Sonnet 4.6 --- .../featured/gradle/R8EliminationTest.kt | 511 +++++++++++++----- 1 file changed, 382 insertions(+), 129 deletions(-) diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/R8EliminationTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/R8EliminationTest.kt index 0109175..01d751d 100644 --- a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/R8EliminationTest.kt +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/R8EliminationTest.kt @@ -9,26 +9,7 @@ import org.junit.Before import org.objectweb.asm.ClassWriter import org.objectweb.asm.ClassWriter.COMPUTE_FRAMES import org.objectweb.asm.Label -import org.objectweb.asm.Opcodes.ACC_PUBLIC -import org.objectweb.asm.Opcodes.ACC_STATIC -import org.objectweb.asm.Opcodes.ALOAD -import org.objectweb.asm.Opcodes.ASTORE -import org.objectweb.asm.Opcodes.DUP -import org.objectweb.asm.Opcodes.GETFIELD -import org.objectweb.asm.Opcodes.GETSTATIC -import org.objectweb.asm.Opcodes.IADD -import org.objectweb.asm.Opcodes.ICONST_1 -import org.objectweb.asm.Opcodes.IFEQ -import org.objectweb.asm.Opcodes.ILOAD -import org.objectweb.asm.Opcodes.INVOKESPECIAL -import org.objectweb.asm.Opcodes.INVOKESTATIC -import org.objectweb.asm.Opcodes.INVOKEVIRTUAL -import org.objectweb.asm.Opcodes.IRETURN -import org.objectweb.asm.Opcodes.NEW -import org.objectweb.asm.Opcodes.PUTFIELD -import org.objectweb.asm.Opcodes.PUTSTATIC -import org.objectweb.asm.Opcodes.RETURN -import org.objectweb.asm.Opcodes.V1_8 +import org.objectweb.asm.Opcodes.* import java.io.File import java.nio.file.Files import java.util.jar.JarEntry @@ -40,44 +21,67 @@ import kotlin.test.assertNull /** * Verifies the library's core guarantee: local flags declared via the Gradle DSL generate - * `-assumevalues` ProGuard/R8 rules that cause R8 to dead-code-eliminate all code reachable - * only when the flag is enabled. + * `-assumevalues` ProGuard/R8 rules that cause R8 to dead-code-eliminate all code that is + * reachable only through the disabled branch of a flag check. * * Strategy: use ASM to build synthetic bytecode that mirrors the plugin-generated structure, - * write a rules file in the exact format [ProguardRulesGenerator] produces, run R8 - * programmatically, and assert presence / absence of the flag-guarded class in the output. + * write rules files in the exact format [ProguardRulesGenerator] produces, run R8 + * programmatically, and assert presence / absence of flag-guarded classes in the output JAR. * - * ### Synthetic class design + * ### Boolean flag — bifurcated caller design * - * ``` + * ```java * // Mirrors dev.androidbroadcast.featured.ConfigValues - * class ConfigValues { boolean enabled; ConfigValues(boolean enabled) { ... } } + * class ConfigValues { boolean enabled; ConfigValues(boolean) } * * // Mirrors ExtensionFunctionGenerator output for module ":test" * class FeaturedTest_FlagExtensionsKt { * static boolean isDarkModeEnabled(ConfigValues cv) { return cv.enabled; } * } * - * // Code that must be absent when the flag is off - * class BehindFlagCode { - * public static int sideEffect; // kept by rule so R8 cannot eliminate the write - * void doWork() { sideEffect++; } - * } + * // Code that must be absent when the flag is disabled (if-branch) + * class IfBranchCode { static int sideEffect; void doWork() { sideEffect++; } } + * + * // Code that must be absent when the flag is enabled (else-branch) + * class ElseBranchCode { static int sideEffect; void doWork() { sideEffect++; } } * - * // Entry point — public method with boolean parameter (unknown value at R8 time) - * class Caller { + * // Entry point kept by -keep; unknown boolean parameter prevents R8 from + * // constant-folding the flag value without an -assumevalues rule. + * class BifurcatedCaller { * static void execute(boolean enabled) { * ConfigValues cv = new ConfigValues(enabled); * if (FeaturedTest_FlagExtensionsKt.isDarkModeEnabled(cv)) { - * new BehindFlagCode().doWork(); + * new IfBranchCode().doWork(); + * } else { + * new ElseBranchCode().doWork(); + * } + * } + * } + * ``` + * + * ### Int flag — positive-guard caller design + * + * ```java + * class IntConfigValues { int count; IntConfigValues(int) } + * + * class FeaturedIntTest_FlagExtensionsKt { + * static int getMaxRetries(IntConfigValues cv) { return cv.count; } + * } + * + * class PositiveCountCode { static int sideEffect; void doWork() { sideEffect++; } } + * + * class IntCaller { + * static void execute(int count) { + * IntConfigValues cv = new IntConfigValues(count); + * if (FeaturedIntTest_FlagExtensionsKt.getMaxRetries(cv) > 0) { + * new PositiveCountCode().doWork(); * } * } * } * ``` * - * Because `execute` is public and kept, R8 cannot infer the value of `enabled`. - * Therefore `isDarkModeEnabled` has an unknown return value **unless** the - * `-assumevalues` rule overrides it. + * When `-assumevalues` pins `getMaxRetries` to `0`, R8 constant-folds `0 > 0` to `false` + * and eliminates the if-branch entirely. */ internal class R8EliminationTest { private lateinit var workDir: File @@ -92,73 +96,117 @@ internal class R8EliminationTest { workDir.deleteRecursively() } - // ── Tests ───────────────────────────────────────────────────────────────── + // ── Boolean flag — elimination tests ────────────────────────────────────── /** - * With `-assumevalues … return false`, R8 treats the flag as permanently disabled. - * The true-branch is dead code, [BEHIND_FLAG_CODE_INTERNAL] becomes unreachable, - * and R8 must eliminate it from the output. + * With `return false`, `isDarkModeEnabled` is pinned to `false` at R8 time. + * The if-branch (`IfBranchCode`) becomes unreachable and must be eliminated; + * the else-branch (`ElseBranchCode`) is the only live path and must survive. */ @Test - fun `class behind disabled local flag is eliminated by R8`() { - val inputJar = workDir.resolve("input.jar").also { buildInputJar(it) } - val rulesFile = workDir.resolve("rules-false.pro").also { writeRulesFile(it) } - val outputJar = workDir.resolve("output-false.jar") + fun `if-branch class is eliminated when boolean flag returns false`() { + val inputJar = workDir.resolve("input.jar").also { buildBooleanInputJar(it) } + val rulesFile = workDir.resolve("rules.pro").also { writeBooleanRules(it, returnValue = false) } + val outputJar = workDir.resolve("output.jar") runR8(inputJar, rulesFile, outputJar) - assertClassPresent(outputJar, CALLER_INTERNAL) - assertClassAbsent(outputJar, BEHIND_FLAG_CODE_INTERNAL) + assertClassAbsent(outputJar, IF_BRANCH_CODE_INTERNAL) + assertClassPresent(outputJar, ELSE_BRANCH_CODE_INTERNAL) + assertClassPresent(outputJar, BIFURCATED_CALLER_INTERNAL) + } + + /** + * With `return true`, `isDarkModeEnabled` is pinned to `true` at R8 time. + * The else-branch (`ElseBranchCode`) becomes unreachable and must be eliminated; + * the if-branch (`IfBranchCode`) is the only live path and must survive. + */ + @Test + fun `else-branch class is eliminated when boolean flag returns true`() { + val inputJar = workDir.resolve("input.jar").also { buildBooleanInputJar(it) } + val rulesFile = workDir.resolve("rules.pro").also { writeBooleanRules(it, returnValue = true) } + val outputJar = workDir.resolve("output.jar") + + runR8(inputJar, rulesFile, outputJar) + + assertClassPresent(outputJar, IF_BRANCH_CODE_INTERNAL) + assertClassAbsent(outputJar, ELSE_BRANCH_CODE_INTERNAL) + assertClassPresent(outputJar, BIFURCATED_CALLER_INTERNAL) } /** * Without any `-assumevalues` rule R8 cannot determine the return value of - * `isDarkModeEnabled` (it depends on an unknown boolean parameter). Both branches - * are potentially reachable, so [BEHIND_FLAG_CODE_INTERNAL] must survive R8. + * `isDarkModeEnabled` (it depends on the unknown `enabled` parameter). Both branches + * are potentially reachable, so both `IfBranchCode` and `ElseBranchCode` must survive. * - * Together with the first test this proves that dead-code elimination is caused + * Together with the two tests above this proves that dead-code elimination is caused * specifically by the generated rule, not by R8's own constant-folding. */ @Test - fun `class behind flag survives R8 when no assumevalues rule is present`() { - val inputJar = workDir.resolve("input.jar").also { buildInputJar(it) } - val rulesFile = workDir.resolve("rules-no-assume.pro").also { writeRulesFileWithoutAssume(it) } - val outputJar = workDir.resolve("output-no-assume.jar") + fun `both branch classes survive when no boolean assumevalues rule is present`() { + val inputJar = workDir.resolve("input.jar").also { buildBooleanInputJar(it) } + val rulesFile = workDir.resolve("rules.pro").also { writeNoBooleanAssumeRules(it) } + val outputJar = workDir.resolve("output.jar") runR8(inputJar, rulesFile, outputJar) - assertClassPresent(outputJar, CALLER_INTERNAL) - assertClassPresent(outputJar, BEHIND_FLAG_CODE_INTERNAL) + assertClassPresent(outputJar, IF_BRANCH_CODE_INTERNAL) + assertClassPresent(outputJar, ELSE_BRANCH_CODE_INTERNAL) } - // ── Synthetic bytecode ──────────────────────────────────────────────────── + // ── Int flag — elimination tests ────────────────────────────────────────── - private fun buildInputJar(dest: File) { - JarOutputStream(dest.outputStream()).use { jos -> - putClass(jos, CONFIG_VALUES_INTERNAL, configValuesBytes()) - putClass(jos, EXTENSIONS_INTERNAL, extensionsBytes()) - putClass(jos, BEHIND_FLAG_CODE_INTERNAL, behindFlagCodeBytes()) - putClass(jos, CALLER_INTERNAL, callerBytes()) - } + /** + * With `return 0`, `getMaxRetries` is pinned to `0`. R8 constant-folds `0 > 0` to + * `false`, making the if-branch dead code. `PositiveCountCode` must be eliminated. + */ + @Test + fun `guarded class is eliminated when int flag is assumed to return zero`() { + val inputJar = workDir.resolve("input.jar").also { buildIntInputJar(it) } + val rulesFile = workDir.resolve("rules.pro").also { writeIntRules(it, returnValue = 0) } + val outputJar = workDir.resolve("output.jar") + + runR8(inputJar, rulesFile, outputJar) + + assertClassAbsent(outputJar, POSITIVE_COUNT_CODE_INTERNAL) + assertClassPresent(outputJar, INT_CALLER_INTERNAL) } - private fun putClass( - jos: JarOutputStream, - internalName: String, - bytes: ByteArray, - ) { - jos.putNextEntry(JarEntry("$internalName.class")) - jos.write(bytes) - jos.closeEntry() + /** + * Without `-assumevalues` R8 cannot determine `getMaxRetries`'s return value. + * The if-branch is potentially reachable so `PositiveCountCode` must survive. + */ + @Test + fun `guarded class survives when int flag has no assumevalues rule`() { + val inputJar = workDir.resolve("input.jar").also { buildIntInputJar(it) } + val rulesFile = workDir.resolve("rules.pro").also { writeNoIntAssumeRules(it) } + val outputJar = workDir.resolve("output.jar") + + runR8(inputJar, rulesFile, outputJar) + + assertClassPresent(outputJar, POSITIVE_COUNT_CODE_INTERNAL) + assertClassPresent(outputJar, INT_CALLER_INTERNAL) + } + + // ── Boolean bytecode builders ───────────────────────────────────────────── + + private fun buildBooleanInputJar(dest: File) { + JarOutputStream(dest.outputStream()).use { jos -> + putClass(jos, CONFIG_VALUES_INTERNAL, booleanConfigValuesBytes()) + putClass(jos, BOOL_EXTENSIONS_INTERNAL, booleanExtensionsBytes()) + putClass(jos, IF_BRANCH_CODE_INTERNAL, sideEffectClassBytes(IF_BRANCH_CODE_INTERNAL)) + putClass(jos, ELSE_BRANCH_CODE_INTERNAL, sideEffectClassBytes(ELSE_BRANCH_CODE_INTERNAL)) + putClass(jos, BIFURCATED_CALLER_INTERNAL, bifurcatedCallerBytes()) + } } /** * `class ConfigValues { boolean enabled; ConfigValues(boolean) }` * - * The constructor parameter makes the field value unknown to R8 when `Caller.execute` - * forwards its own unknown parameter: `new ConfigValues(enabled)`. + * The constructor parameter makes the field value unknown to R8 when `BifurcatedCaller` + * forwards its own unknown `enabled` parameter: `new ConfigValues(enabled)`. */ - private fun configValuesBytes(): ByteArray = + private fun booleanConfigValuesBytes(): ByteArray = safeClassWriter() .apply { visit(V1_8, ACC_PUBLIC, CONFIG_VALUES_INTERNAL, null, OBJECT, null) @@ -181,16 +229,19 @@ internal class R8EliminationTest { * Mirrors [ExtensionFunctionGenerator]'s output for module `":test"`: * `static boolean isDarkModeEnabled(ConfigValues cv) { return cv.enabled; }` * - * Reading an instance field whose value derives from an unknown parameter is - * something R8 cannot constant-fold — exactly like the real extension function - * that reads from `ConfigValues` at runtime. The `-assumevalues` rule - * overrides this return value to a build-time constant. + * The `-assumevalues` rule overrides this return value to a build-time constant. */ - private fun extensionsBytes(): ByteArray = + private fun booleanExtensionsBytes(): ByteArray = safeClassWriter() .apply { - visit(V1_8, ACC_PUBLIC, EXTENSIONS_INTERNAL, null, OBJECT, null) - visitMethod(ACC_PUBLIC or ACC_STATIC, IS_DARK_MODE_ENABLED, "(L$CONFIG_VALUES_INTERNAL;)Z", null, null).apply { + visit(V1_8, ACC_PUBLIC, BOOL_EXTENSIONS_INTERNAL, null, OBJECT, null) + visitMethod( + ACC_PUBLIC or ACC_STATIC, + IS_DARK_MODE_ENABLED, + "(L$CONFIG_VALUES_INTERNAL;)Z", + null, + null, + ).apply { visitCode() visitVarInsn(ALOAD, 0) visitFieldInsn(GETFIELD, CONFIG_VALUES_INTERNAL, "enabled", "Z") @@ -202,31 +253,116 @@ internal class R8EliminationTest { }.toByteArray() /** - * Code that must be absent when the flag is disabled. + * Entry point with both if and else branches: + * + * ```java + * static void execute(boolean enabled) { + * ConfigValues cv = new ConfigValues(enabled); + * if (BoolExtensions.isDarkModeEnabled(cv)) { + * new IfBranchCode().doWork(); + * } else { + * new ElseBranchCode().doWork(); + * } + * } + * ``` * - * `doWork()` writes to a public static field so R8 cannot treat the call as a - * no-op and remove the instantiation when the branch is live. + * A single input JAR covers all four Boolean scenarios: + * `flag=false/true` × `if/else` branch elimination. */ - private fun behindFlagCodeBytes(): ByteArray = + private fun bifurcatedCallerBytes(): ByteArray = safeClassWriter() .apply { - visit(V1_8, ACC_PUBLIC, BEHIND_FLAG_CODE_INTERNAL, null, OBJECT, null) - visitField(ACC_PUBLIC or ACC_STATIC, "sideEffect", "I", null, 0).visitEnd() - visitMethod(ACC_PUBLIC, "", "()V", null, null).apply { + visit(V1_8, ACC_PUBLIC, BIFURCATED_CALLER_INTERNAL, null, OBJECT, null) + visitMethod(ACC_PUBLIC or ACC_STATIC, "execute", "(Z)V", null, null).apply { + visitCode() + visitTypeInsn(NEW, CONFIG_VALUES_INTERNAL) + visitInsn(DUP) + visitVarInsn(ILOAD, 0) + visitMethodInsn(INVOKESPECIAL, CONFIG_VALUES_INTERNAL, "", "(Z)V", false) + visitVarInsn(ASTORE, 1) + visitVarInsn(ALOAD, 1) + visitMethodInsn( + INVOKESTATIC, + BOOL_EXTENSIONS_INTERNAL, + IS_DARK_MODE_ENABLED, + "(L$CONFIG_VALUES_INTERNAL;)Z", + false, + ) + val elseLabel = Label() + val endLabel = Label() + visitJumpInsn(IFEQ, elseLabel) + // if-branch + visitTypeInsn(NEW, IF_BRANCH_CODE_INTERNAL) + visitInsn(DUP) + visitMethodInsn(INVOKESPECIAL, IF_BRANCH_CODE_INTERNAL, "", "()V", false) + visitMethodInsn(INVOKEVIRTUAL, IF_BRANCH_CODE_INTERNAL, "doWork", "()V", false) + visitJumpInsn(GOTO, endLabel) + // else-branch + visitLabel(elseLabel) + visitTypeInsn(NEW, ELSE_BRANCH_CODE_INTERNAL) + visitInsn(DUP) + visitMethodInsn(INVOKESPECIAL, ELSE_BRANCH_CODE_INTERNAL, "", "()V", false) + visitMethodInsn(INVOKEVIRTUAL, ELSE_BRANCH_CODE_INTERNAL, "doWork", "()V", false) + visitLabel(endLabel) + visitInsn(RETURN) + visitMaxs(0, 0) + visitEnd() + } + visitEnd() + }.toByteArray() + + // ── Int bytecode builders ───────────────────────────────────────────────── + + private fun buildIntInputJar(dest: File) { + JarOutputStream(dest.outputStream()).use { jos -> + putClass(jos, INT_CONFIG_VALUES_INTERNAL, intConfigValuesBytes()) + putClass(jos, INT_EXTENSIONS_INTERNAL, intExtensionsBytes()) + putClass(jos, POSITIVE_COUNT_CODE_INTERNAL, sideEffectClassBytes(POSITIVE_COUNT_CODE_INTERNAL)) + putClass(jos, INT_CALLER_INTERNAL, intCallerBytes()) + } + } + + /** + * `class IntConfigValues { int count; IntConfigValues(int) }` + */ + private fun intConfigValuesBytes(): ByteArray = + safeClassWriter() + .apply { + visit(V1_8, ACC_PUBLIC, INT_CONFIG_VALUES_INTERNAL, null, OBJECT, null) + visitField(ACC_PUBLIC, "count", "I", null, null).visitEnd() + visitMethod(ACC_PUBLIC, "", "(I)V", null, null).apply { visitCode() visitVarInsn(ALOAD, 0) visitMethodInsn(INVOKESPECIAL, OBJECT, "", "()V", false) + visitVarInsn(ALOAD, 0) + visitVarInsn(ILOAD, 1) + visitFieldInsn(PUTFIELD, INT_CONFIG_VALUES_INTERNAL, "count", "I") visitInsn(RETURN) visitMaxs(0, 0) visitEnd() } - visitMethod(ACC_PUBLIC, "doWork", "()V", null, null).apply { + visitEnd() + }.toByteArray() + + /** + * Mirrors [ExtensionFunctionGenerator]'s output for module `":int-test"`: + * `static int getMaxRetries(IntConfigValues cv) { return cv.count; }` + */ + private fun intExtensionsBytes(): ByteArray = + safeClassWriter() + .apply { + visit(V1_8, ACC_PUBLIC, INT_EXTENSIONS_INTERNAL, null, OBJECT, null) + visitMethod( + ACC_PUBLIC or ACC_STATIC, + GET_MAX_RETRIES, + "(L$INT_CONFIG_VALUES_INTERNAL;)I", + null, + null, + ).apply { visitCode() - visitFieldInsn(GETSTATIC, BEHIND_FLAG_CODE_INTERNAL, "sideEffect", "I") - visitInsn(ICONST_1) - visitInsn(IADD) - visitFieldInsn(PUTSTATIC, BEHIND_FLAG_CODE_INTERNAL, "sideEffect", "I") - visitInsn(RETURN) + visitVarInsn(ALOAD, 0) + visitFieldInsn(GETFIELD, INT_CONFIG_VALUES_INTERNAL, "count", "I") + visitInsn(IRETURN) visitMaxs(0, 0) visitEnd() } @@ -234,31 +370,43 @@ internal class R8EliminationTest { }.toByteArray() /** - * Entry point: `static void execute(boolean enabled)`. + * ```java + * static void execute(int count) { + * IntConfigValues cv = new IntConfigValues(count); + * if (IntExtensions.getMaxRetries(cv) > 0) { + * new PositiveCountCode().doWork(); + * } + * } + * ``` * - * The boolean parameter is unknown at R8 time because `execute` is public and kept. - * That makes `isDarkModeEnabled`'s return value unknown — unless overridden by - * `-assumevalues`. + * `IFLE` (jump if ≤ 0) is the branch that skips the block when count is zero. + * With `-assumevalues return 0`, R8 folds `0 > 0` to `false` and eliminates the block. */ - private fun callerBytes(): ByteArray = + private fun intCallerBytes(): ByteArray = safeClassWriter() .apply { - visit(V1_8, ACC_PUBLIC, CALLER_INTERNAL, null, OBJECT, null) - visitMethod(ACC_PUBLIC or ACC_STATIC, "execute", "(Z)V", null, null).apply { + visit(V1_8, ACC_PUBLIC, INT_CALLER_INTERNAL, null, OBJECT, null) + visitMethod(ACC_PUBLIC or ACC_STATIC, "execute", "(I)V", null, null).apply { visitCode() - visitTypeInsn(NEW, CONFIG_VALUES_INTERNAL) + visitTypeInsn(NEW, INT_CONFIG_VALUES_INTERNAL) visitInsn(DUP) visitVarInsn(ILOAD, 0) - visitMethodInsn(INVOKESPECIAL, CONFIG_VALUES_INTERNAL, "", "(Z)V", false) + visitMethodInsn(INVOKESPECIAL, INT_CONFIG_VALUES_INTERNAL, "", "(I)V", false) visitVarInsn(ASTORE, 1) visitVarInsn(ALOAD, 1) - visitMethodInsn(INVOKESTATIC, EXTENSIONS_INTERNAL, IS_DARK_MODE_ENABLED, "(L$CONFIG_VALUES_INTERNAL;)Z", false) + visitMethodInsn( + INVOKESTATIC, + INT_EXTENSIONS_INTERNAL, + GET_MAX_RETRIES, + "(L$INT_CONFIG_VALUES_INTERNAL;)I", + false, + ) val skipLabel = Label() - visitJumpInsn(IFEQ, skipLabel) - visitTypeInsn(NEW, BEHIND_FLAG_CODE_INTERNAL) + visitJumpInsn(IFLE, skipLabel) + visitTypeInsn(NEW, POSITIVE_COUNT_CODE_INTERNAL) visitInsn(DUP) - visitMethodInsn(INVOKESPECIAL, BEHIND_FLAG_CODE_INTERNAL, "", "()V", false) - visitMethodInsn(INVOKEVIRTUAL, BEHIND_FLAG_CODE_INTERNAL, "doWork", "()V", false) + visitMethodInsn(INVOKESPECIAL, POSITIVE_COUNT_CODE_INTERNAL, "", "()V", false) + visitMethodInsn(INVOKEVIRTUAL, POSITIVE_COUNT_CODE_INTERNAL, "doWork", "()V", false) visitLabel(skipLabel) visitInsn(RETURN) visitMaxs(0, 0) @@ -267,35 +415,119 @@ internal class R8EliminationTest { visitEnd() }.toByteArray() + // ── Shared bytecode builder ─────────────────────────────────────────────── + + /** + * Builds a class with: + * - `public static int sideEffect` — keeps R8 from treating `doWork()` as a no-op + * - `public void doWork()` — increments `sideEffect` + * + * Used for all branch-target classes so they share the same structure. + */ + private fun sideEffectClassBytes(internalName: String): ByteArray = + safeClassWriter() + .apply { + visit(V1_8, ACC_PUBLIC, internalName, null, OBJECT, null) + visitField(ACC_PUBLIC or ACC_STATIC, "sideEffect", "I", null, 0).visitEnd() + visitMethod(ACC_PUBLIC, "", "()V", null, null).apply { + visitCode() + visitVarInsn(ALOAD, 0) + visitMethodInsn(INVOKESPECIAL, OBJECT, "", "()V", false) + visitInsn(RETURN) + visitMaxs(0, 0) + visitEnd() + } + visitMethod(ACC_PUBLIC, "doWork", "()V", null, null).apply { + visitCode() + visitFieldInsn(GETSTATIC, internalName, "sideEffect", "I") + visitInsn(ICONST_1) + visitInsn(IADD) + visitFieldInsn(PUTSTATIC, internalName, "sideEffect", "I") + visitInsn(RETURN) + visitMaxs(0, 0) + visitEnd() + } + visitEnd() + }.toByteArray() + + private fun putClass( + jos: JarOutputStream, + internalName: String, + bytes: ByteArray, + ) { + jos.putNextEntry(JarEntry("$internalName.class")) + jos.write(bytes) + jos.closeEntry() + } + // ── ProGuard rules ──────────────────────────────────────────────────────── /** - * Approximates the output of [ProguardRulesGenerator.generate] for module `":test"`, - * Boolean flag `"dark_mode"` with `defaultValue = false`. The `-keep` and `-dontwarn` - * directives are test scaffolding, not generator output. + * Approximates [ProguardRulesGenerator] output for a Boolean flag `"dark_mode"` in + * module `":test"`. The `-keep` and `-dontwarn` directives are test scaffolding only. + * + * `-keepclassmembers` pins the `sideEffect` field of the **surviving** branch class so + * that R8 cannot treat the `doWork()` call as a no-op and eliminate the class via + * write-only field optimisation. The dead branch class intentionally has no such rule, + * so R8 is free to eliminate it once the branch becomes unreachable. */ - private fun writeRulesFile(dest: File) { + private fun writeBooleanRules( + dest: File, + returnValue: Boolean, + ) { + val survivingClass = if (returnValue) IF_BRANCH_CODE_FQN else ELSE_BRANCH_CODE_FQN dest.writeText( """ - -assumevalues class $EXTENSIONS_FQN { - boolean $IS_DARK_MODE_ENABLED($CONFIG_VALUES_FQN) return false; + -assumevalues class $BOOL_EXTENSIONS_FQN { + boolean $IS_DARK_MODE_ENABLED($CONFIG_VALUES_FQN) return $returnValue; } - -keep class $CALLER_FQN { *; } + -keep class $BIFURCATED_CALLER_FQN { *; } + -keepclassmembers class $survivingClass { public static int sideEffect; } -dontwarn ** """.trimIndent(), ) } /** - * Rules without any `-assumevalues` block. - * [BehindFlagCode.sideEffect] is kept so R8 cannot treat `doWork()` as a no-op - * when the branch is live (unknown flag value). + * No `-assumevalues` block — R8 cannot constant-fold the flag value. + * The `-keepclassmembers` rules ensure the `sideEffect` field is not stripped + * while the branch-target classes remain alive via reachability from the kept caller. */ - private fun writeRulesFileWithoutAssume(dest: File) { + private fun writeNoBooleanAssumeRules(dest: File) { dest.writeText( """ - -keep class $CALLER_FQN { *; } - -keepclassmembers class $BEHIND_FLAG_CODE_FQN { public static int sideEffect; } + -keep class $BIFURCATED_CALLER_FQN { *; } + -keepclassmembers class $IF_BRANCH_CODE_FQN { public static int sideEffect; } + -keepclassmembers class $ELSE_BRANCH_CODE_FQN { public static int sideEffect; } + -dontwarn ** + """.trimIndent(), + ) + } + + /** + * Approximates [ProguardRulesGenerator] output for an Int flag `"max_retries"` in + * module `":int-test"` with the given [returnValue]. + */ + private fun writeIntRules( + dest: File, + returnValue: Int, + ) { + dest.writeText( + """ + -assumevalues class $INT_EXTENSIONS_FQN { + int $GET_MAX_RETRIES($INT_CONFIG_VALUES_FQN) return $returnValue; + } + -keep class $INT_CALLER_FQN { *; } + -dontwarn ** + """.trimIndent(), + ) + } + + private fun writeNoIntAssumeRules(dest: File) { + dest.writeText( + """ + -keep class $INT_CALLER_FQN { *; } + -keepclassmembers class $POSITIVE_COUNT_CODE_FQN { public static int sideEffect; } -dontwarn ** """.trimIndent(), ) @@ -349,19 +581,40 @@ internal class R8EliminationTest { // ── Constants ───────────────────────────────────────────────────────────── private companion object { + // Boolean flag — class names (JVM internal form) const val CONFIG_VALUES_INTERNAL = "dev/androidbroadcast/featured/ConfigValues" - const val BEHIND_FLAG_CODE_INTERNAL = "BehindFlagCode" - const val CALLER_INTERNAL = "Caller" + const val IF_BRANCH_CODE_INTERNAL = "IfBranchCode" + const val ELSE_BRANCH_CODE_INTERNAL = "ElseBranchCode" + const val BIFURCATED_CALLER_INTERNAL = "BifurcatedCaller" - val EXTENSIONS_INTERNAL = + val BOOL_EXTENSIONS_INTERNAL = "dev/androidbroadcast/featured/generated/${ExtensionFunctionGenerator.jvmFileName(":test")}" + // Boolean flag — FQN form for ProGuard rules const val CONFIG_VALUES_FQN = "dev.androidbroadcast.featured.ConfigValues" - const val CALLER_FQN = "Caller" - const val BEHIND_FLAG_CODE_FQN = "BehindFlagCode" - val EXTENSIONS_FQN = EXTENSIONS_INTERNAL.replace('/', '.') + const val IF_BRANCH_CODE_FQN = "IfBranchCode" + const val ELSE_BRANCH_CODE_FQN = "ElseBranchCode" + const val BIFURCATED_CALLER_FQN = "BifurcatedCaller" + val BOOL_EXTENSIONS_FQN = BOOL_EXTENSIONS_INTERNAL.replace('/', '.') const val IS_DARK_MODE_ENABLED = "isDarkModeEnabled" + + // Int flag — class names (JVM internal form) + const val INT_CONFIG_VALUES_INTERNAL = "dev/androidbroadcast/featured/IntConfigValues" + const val POSITIVE_COUNT_CODE_INTERNAL = "PositiveCountCode" + const val INT_CALLER_INTERNAL = "IntCaller" + + val INT_EXTENSIONS_INTERNAL = + "dev/androidbroadcast/featured/generated/${ExtensionFunctionGenerator.jvmFileName(":int-test")}" + + // Int flag — FQN form for ProGuard rules + const val INT_CONFIG_VALUES_FQN = "dev.androidbroadcast.featured.IntConfigValues" + const val POSITIVE_COUNT_CODE_FQN = "PositiveCountCode" + const val INT_CALLER_FQN = "IntCaller" + val INT_EXTENSIONS_FQN = INT_EXTENSIONS_INTERNAL.replace('/', '.') + + const val GET_MAX_RETRIES = "getMaxRetries" + const val OBJECT = "java/lang/Object" } } From 64a964cec9ca3f56a6688047ab722877695d1449 Mon Sep 17 00:00:00 2001 From: kirich1409 Date: Fri, 3 Apr 2026 12:30:30 +0300 Subject: [PATCH 3/4] style(gradle-plugin): extract R8 test setup helpers; fix wildcard import - Extract runBooleanR8/runIntR8/runR8WithJar helpers to eliminate repeated three-line jar/rules/output setup from every test method - Expand Opcodes.* wildcard to explicit imports (ktlint standard:no-wildcard-imports) - Apply spotless formatting Co-Authored-By: Claude Sonnet 4.6 --- .../featured/gradle/R8EliminationTest.kt | 76 ++++++++++++------- 1 file changed, 50 insertions(+), 26 deletions(-) diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/R8EliminationTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/R8EliminationTest.kt index 01d751d..f2686fd 100644 --- a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/R8EliminationTest.kt +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/R8EliminationTest.kt @@ -9,7 +9,28 @@ import org.junit.Before import org.objectweb.asm.ClassWriter import org.objectweb.asm.ClassWriter.COMPUTE_FRAMES import org.objectweb.asm.Label -import org.objectweb.asm.Opcodes.* +import org.objectweb.asm.Opcodes.ACC_PUBLIC +import org.objectweb.asm.Opcodes.ACC_STATIC +import org.objectweb.asm.Opcodes.ALOAD +import org.objectweb.asm.Opcodes.ASTORE +import org.objectweb.asm.Opcodes.DUP +import org.objectweb.asm.Opcodes.GETFIELD +import org.objectweb.asm.Opcodes.GETSTATIC +import org.objectweb.asm.Opcodes.GOTO +import org.objectweb.asm.Opcodes.IADD +import org.objectweb.asm.Opcodes.ICONST_1 +import org.objectweb.asm.Opcodes.IFEQ +import org.objectweb.asm.Opcodes.IFLE +import org.objectweb.asm.Opcodes.ILOAD +import org.objectweb.asm.Opcodes.INVOKESPECIAL +import org.objectweb.asm.Opcodes.INVOKESTATIC +import org.objectweb.asm.Opcodes.INVOKEVIRTUAL +import org.objectweb.asm.Opcodes.IRETURN +import org.objectweb.asm.Opcodes.NEW +import org.objectweb.asm.Opcodes.PUTFIELD +import org.objectweb.asm.Opcodes.PUTSTATIC +import org.objectweb.asm.Opcodes.RETURN +import org.objectweb.asm.Opcodes.V1_8 import java.io.File import java.nio.file.Files import java.util.jar.JarEntry @@ -105,11 +126,7 @@ internal class R8EliminationTest { */ @Test fun `if-branch class is eliminated when boolean flag returns false`() { - val inputJar = workDir.resolve("input.jar").also { buildBooleanInputJar(it) } - val rulesFile = workDir.resolve("rules.pro").also { writeBooleanRules(it, returnValue = false) } - val outputJar = workDir.resolve("output.jar") - - runR8(inputJar, rulesFile, outputJar) + val outputJar = runBooleanR8 { writeBooleanRules(it, returnValue = false) } assertClassAbsent(outputJar, IF_BRANCH_CODE_INTERNAL) assertClassPresent(outputJar, ELSE_BRANCH_CODE_INTERNAL) @@ -123,11 +140,7 @@ internal class R8EliminationTest { */ @Test fun `else-branch class is eliminated when boolean flag returns true`() { - val inputJar = workDir.resolve("input.jar").also { buildBooleanInputJar(it) } - val rulesFile = workDir.resolve("rules.pro").also { writeBooleanRules(it, returnValue = true) } - val outputJar = workDir.resolve("output.jar") - - runR8(inputJar, rulesFile, outputJar) + val outputJar = runBooleanR8 { writeBooleanRules(it, returnValue = true) } assertClassPresent(outputJar, IF_BRANCH_CODE_INTERNAL) assertClassAbsent(outputJar, ELSE_BRANCH_CODE_INTERNAL) @@ -144,11 +157,7 @@ internal class R8EliminationTest { */ @Test fun `both branch classes survive when no boolean assumevalues rule is present`() { - val inputJar = workDir.resolve("input.jar").also { buildBooleanInputJar(it) } - val rulesFile = workDir.resolve("rules.pro").also { writeNoBooleanAssumeRules(it) } - val outputJar = workDir.resolve("output.jar") - - runR8(inputJar, rulesFile, outputJar) + val outputJar = runBooleanR8 { writeNoBooleanAssumeRules(it) } assertClassPresent(outputJar, IF_BRANCH_CODE_INTERNAL) assertClassPresent(outputJar, ELSE_BRANCH_CODE_INTERNAL) @@ -162,11 +171,7 @@ internal class R8EliminationTest { */ @Test fun `guarded class is eliminated when int flag is assumed to return zero`() { - val inputJar = workDir.resolve("input.jar").also { buildIntInputJar(it) } - val rulesFile = workDir.resolve("rules.pro").also { writeIntRules(it, returnValue = 0) } - val outputJar = workDir.resolve("output.jar") - - runR8(inputJar, rulesFile, outputJar) + val outputJar = runIntR8 { writeIntRules(it, returnValue = 0) } assertClassAbsent(outputJar, POSITIVE_COUNT_CODE_INTERNAL) assertClassPresent(outputJar, INT_CALLER_INTERNAL) @@ -178,11 +183,7 @@ internal class R8EliminationTest { */ @Test fun `guarded class survives when int flag has no assumevalues rule`() { - val inputJar = workDir.resolve("input.jar").also { buildIntInputJar(it) } - val rulesFile = workDir.resolve("rules.pro").also { writeNoIntAssumeRules(it) } - val outputJar = workDir.resolve("output.jar") - - runR8(inputJar, rulesFile, outputJar) + val outputJar = runIntR8 { writeNoIntAssumeRules(it) } assertClassPresent(outputJar, POSITIVE_COUNT_CODE_INTERNAL) assertClassPresent(outputJar, INT_CALLER_INTERNAL) @@ -535,6 +536,29 @@ internal class R8EliminationTest { // ── R8 invocation ───────────────────────────────────────────────────────── + /** + * Builds the Boolean input JAR, writes rules via [writeRules], runs R8, and returns + * the output JAR for assertion. + */ + private fun runBooleanR8(writeRules: (File) -> Unit): File = runR8WithJar(::buildBooleanInputJar, writeRules) + + /** + * Builds the Int input JAR, writes rules via [writeRules], runs R8, and returns + * the output JAR for assertion. + */ + private fun runIntR8(writeRules: (File) -> Unit): File = runR8WithJar(::buildIntInputJar, writeRules) + + private fun runR8WithJar( + buildInputJar: (File) -> Unit, + writeRules: (File) -> Unit, + ): File { + val inputJar = workDir.resolve("input.jar").also(buildInputJar) + val rulesFile = workDir.resolve("rules.pro").also(writeRules) + val outputJar = workDir.resolve("output.jar") + runR8(inputJar, rulesFile, outputJar) + return outputJar + } + private fun runR8( inputJar: File, rulesFile: File, From baeb076b47071b79c57f7b7c8ce84b06e96ffbba Mon Sep 17 00:00:00 2001 From: kirich1409 Date: Fri, 3 Apr 2026 12:39:55 +0300 Subject: [PATCH 4/4] fix(firebase): rethrow CancellationException before wrapping in FetchException MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removing the RuntimeException catch in the previous commit inadvertently caused CancellationException (a RuntimeException subclass) to be swallowed and wrapped in FetchException, breaking structured concurrency. Also restore pull_request trigger in docs.yml — publish-docs is already gated with `if: github.event_name == 'push'` so docs are validated on PRs but not published. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/docs.yml | 3 +++ .../featured/firebase/FirebaseConfigValueProvider.kt | 3 +++ 2 files changed, 6 insertions(+) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index b0a5308..a5624cd 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -4,6 +4,9 @@ on: push: branches: - main + pull_request: + branches: + - main tags: - "v[0-9]+.[0-9]+.[0-9]+" - "v[0-9]+.[0-9]+.[0-9]+-*" diff --git a/providers/firebase/src/main/kotlin/dev/androidbroadcast/featured/firebase/FirebaseConfigValueProvider.kt b/providers/firebase/src/main/kotlin/dev/androidbroadcast/featured/firebase/FirebaseConfigValueProvider.kt index 673fee8..6d172ad 100644 --- a/providers/firebase/src/main/kotlin/dev/androidbroadcast/featured/firebase/FirebaseConfigValueProvider.kt +++ b/providers/firebase/src/main/kotlin/dev/androidbroadcast/featured/firebase/FirebaseConfigValueProvider.kt @@ -5,6 +5,7 @@ import com.google.firebase.remoteconfig.FirebaseRemoteConfigValue import dev.androidbroadcast.featured.ConfigParam import dev.androidbroadcast.featured.ConfigValue import dev.androidbroadcast.featured.RemoteConfigValueProvider +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.tasks.await import kotlin.reflect.KClass @@ -98,6 +99,8 @@ public class FirebaseConfigValueProvider( try { task.await() + } catch (e: CancellationException) { + throw e } catch (e: Exception) { throw FetchException("Firebase Remote Config fetch failed", e) }