diff --git a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java index 1a5ac2d7a60..b7790ae0603 100644 --- a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java +++ b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java @@ -638,6 +638,29 @@ static void register(List h) { .doc("Boolean true/false defaults to true. Toggles the new async keyboard mode that leaves the " + "keyboard open while you move between text components")); + h.add(new Hint("android.kotlinStdlibAlignment") + .group(HintGroup.ANDROID) + .type(HintType.BOOLEAN) + .def("true") + .platform("android") + .doc("Boolean true/false defaults to true. Kotlin 1.8.0 moved the contents of " + + "`kotlin-stdlib-jdk7` and `kotlin-stdlib-jdk8` into `kotlin-stdlib` " + + "and left the two shims empty. A build that reaches `kotlin-stdlib` " + + "1.8 or newer through one dependency and an older " + + "`kotlin-stdlib-jdk8` through another then carries the same classes " + + "twice and fails in `checkReleaseDuplicateClasses`, naming Kotlin " + + "artifacts you never asked for. The 1.8.x line ships no Gradle " + + "module metadata to say the two overlap; from 1.9.22 JetBrains " + + "ships it. This adds that missing statement, as a Gradle " + + "capability: from 1.8.0 up, `kotlin-stdlib` provides what the shims " + + "provide, so Gradle drops the redundant shim. It moves no version, " + + "which is what keeps it out of your way -- a version pin, a force, " + + "an enforced BOM, a range or a Kotlin compiler older than 1.8 all " + + "resolve exactly as they did without it. Below 1.8.0 nothing " + + "happens at all, because there the shims still hold the only copy " + + "of their classes. Set to false to manage these coordinates " + + "yourself.")); + h.add(new Hint("android.largeScreens") .group(HintGroup.ANDROID) .type(HintType.BOOLEAN) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index f0b655f35c2..ee48e157d34 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -7270,6 +7270,58 @@ && watchModuleName(request) != null && legacyGplayServicesMode) { namespace = "namespace '"+request.getPackageName()+"'\n"; } + // Kotlin stdlib alignment, emitted for every AndroidX build rather than + // for Kotlin-shaped apps: the duplicate class it prevents is produced by + // ordinary AndroidX and Play dependencies, not by anything the app wrote. + // See KotlinStdlibAlignment for the mechanism and for why Gradle cannot + // work it out for itself on the kotlin-stdlib 1.8.x line. A constraint + // adds nothing to a graph that has no Kotlin in it, so an app that could + // never hit the clash resolves exactly as it did before. + // + // Gated on AndroidX because that is what decides the configuration name a few + // lines below: `compile` is only "implementation" when useAndroidX or the aar + // implementation flag is set, so a useAndroidX=false build would take this + // block on the legacy `compile` configuration. Reviewed as an unrelated flag + // to gate on -- it is not, and the failing case it is meant to protect needs + // a modern AndroidX dependency in a project that has AndroidX turned off, + // which AGP refuses for its own reasons before this could matter. That + // whole line of reasoning turned out not to matter either: see the + // useAndroidX note on the gate below. + // + // On Gradle 6 rather than on 4.6 where the constraints + // DSL first appeared. That is deliberate, and it has been questioned in + // review, so: 4.6 selects AGP 3.2.0, which cannot compile against a + // compileSdk the current AndroidX releases require, and the builder gives + // that path appcompat 1.0.0, whose graph contains no Kotlin at all. A graph + // that reaches a merged kotlin-stdlib cannot occur there. Widening the gate + // would put an untested constraints block into AGP 3.x builds that work + // today, to fix a clash they cannot have -- and the two failure directions + // are not symmetrical: too narrow leaves an ancient build with a failure it + // already had, too wide breaks a build that currently succeeds. Raise this + // gate only with a reproduction on that path. + // No inputs. This used to collect every Gradle fragment the app + // controls and search it for signs that the app was holding a stdlib + // version down, because the alignment RAISED one and could then break a + // build that resolved. It declares a capability now, which raises + // nothing, so there is nothing to search for -- see KotlinStdlibAlignment. + // + // Not gated on useAndroidX any more. It was, on the reasoning above that + // a non-AndroidX graph cannot reach a merged kotlin-stdlib -- and that + // reasoning is wrong, because the duplicate has nothing to do with + // AndroidX. Reproduced with android.useAndroidX=false explicitly set, + // AGP 8.1.4, kotlin-stdlib 1.8.10 beside kotlin-stdlib-jdk8 1.6.21: + // checkDebugDuplicateClasses fails exactly as it does with AndroidX on, + // and passes with this script. The old gate left those builds broken. + // + // The Gradle 6 floor stays, and for a reason that did survive + // measurement: capabilitiesResolution is the mechanism here, and AGP 3.x + // on Gradle 4.6 is a different world. Turning it off is the hint. + String kotlinStdlibAlignment = ""; + if (gradleVersionInt >= 6 + && request.getArg("android.kotlinStdlibAlignment", "true").equals("true")) { + kotlinStdlibAlignment = KotlinStdlibAlignment.alignmentScript(); + } + String gradleProps = "apply plugin: 'com.android.application'\n" + kotlinPluginApply + request.getArg("android.gradlePlugin", "") @@ -7362,6 +7414,12 @@ && watchModuleName(request) != null && legacyGplayServicesMode) { + addNewlineIfMissing(request.getArg("android.gradleDep", "")) + addNewlineIfMissing(aarDependencies) + "}\n" + // After the dependencies block, not inside it: the alignment + // needs a component metadata rule (which lives in dependencies) + // AND a resolution strategy (which does not), so it brings its + // own dependencies block rather than being spliced into two + // places. + + kotlinStdlibAlignment + request.getArg("android.xgradle", ""); debug("Gradle File start\n-------\n"); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java new file mode 100644 index 00000000000..41c9dd24c13 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/KotlinStdlibAlignment.java @@ -0,0 +1,228 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.builders; + +/** + * The Kotlin stdlib alignment written into the generated Android + * {@code build.gradle}. + * + *

The failure it prevents. Kotlin 1.8.0 folded the contents of + * {@code kotlin-stdlib-jdk7} and {@code kotlin-stdlib-jdk8} into + * {@code kotlin-stdlib} and left the two shims empty. A graph that reaches + * {@code kotlin-stdlib} 1.8 or newer through one dependency and an older + * {@code kotlin-stdlib-jdk8} through another therefore carries the same classes + * twice, and the build fails in {@code checkDuplicateClasses} naming Kotlin + * artifacts the app never asked for. The 1.8.x line ships no Gradle module + * metadata saying the two overlap; from 1.9.22 JetBrains ships it.

+ * + *

Why a capability and not a version constraint. This was a + * constraint raising both shims to the floor, and a constraint raises a + * version -- which is a thing an app can be holding down. Measured against + * a real Gradle, a strict pin or a {@code reject} on a shim turns into + * {@code Could not resolve ... {strictly 1.6.21}}: a build that resolved before + * the alignment and does not after it. Guarding that by reading the app's own + * Gradle for signs of a pin is an unbounded problem, and every round of review + * found another spelling it missed.

+ * + *

Declaring the overlap as a capability has no such failure mode. It + * states a fact -- from the floor up, {@code kotlin-stdlib} provides what the + * shims provide -- and lets Gradle drop the redundant shim. No version moves, + * so there is nothing for a pin, a force, an enforced BOM, a range, a lock or a + * Kotlin compiler version to conflict with, and nothing to detect. That is why + * this class has no inputs.

+ * + *

Measured, not reasoned. The emitted script was run against real + * Gradle 6.5 (the builder's default) and 8.5 resolving from Maven Central, and + * end to end through {@code checkDebugDuplicateClasses} in a real Android + * project on AGP 8.1.4:

+ * + * + */ +public class KotlinStdlibAlignment { + + /** + * The version at which the shims became empty, and the version from which + * {@code kotlin-stdlib} is declared to provide their capabilities. + */ + public static final String MERGED_STDLIB_FLOOR = "1.8.0"; + + /** The two shims whose classes moved into {@code kotlin-stdlib}. */ + private static final String[] ALIGNED_ARTIFACTS = { + "kotlin-stdlib-jdk7", + "kotlin-stdlib-jdk8" + }; + + /** + * The group of the capability this declares, and the name suffix. + * + *

Ours, deliberately, rather than reusing the shims' own implicit + * capability. That one is held by EVERY version of a shim, including the + * empty ones at or above the floor -- and a conflict there has no right + * answer: dropping the shim loses its requirement on a newer stdlib and + * silently downgrades the base module, while dropping the stdlib leaves a + * graph of empty shims with no stdlib in it at all. Both were measured.

+ * + *

A capability only this declares is held by exactly two things: a + * {@code kotlin-stdlib} at or above the floor, which supersedes the shims, + * and a shim below it, which is superseded. So the conflict exists where the + * duplicate exists and nowhere else. It cannot be removed from the shims + * instead -- {@code removeCapability} does not remove an implicit one, which + * was tried and measured too.

+ */ + private static final String CAPABILITY_GROUP = "com.codenameone"; + + /** @see #CAPABILITY_GROUP */ + private static final String CAPABILITY_SUFFIX = "-superseded"; + + private KotlinStdlibAlignment() { + } + + /** + * The alignment, as a self-contained script to append after the generated + * {@code dependencies { }} block. + * + *

Self-contained because it needs two different scopes: the component + * metadata rule belongs inside {@code dependencies}, the resolution + * strategy outside it. It opens its own {@code dependencies} block rather + * than making the caller splice two pieces into two places.

+ * + * @return the script, newline terminated + */ + public static String alignmentScript() { + String major = MERGED_STDLIB_FLOOR.substring(0, MERGED_STDLIB_FLOOR.indexOf('.')); + String rest = MERGED_STDLIB_FLOOR.substring(MERGED_STDLIB_FLOOR.indexOf('.') + 1); + String minor = rest.substring(0, rest.indexOf('.')); + String atOrAbove = "major > " + major + " || (major == " + major + + " && minor >= " + minor + ")"; + String below = "major < " + major + " || (major == " + major + + " && minor < " + minor + ")"; + + StringBuilder out = new StringBuilder(); + out.append("\n") + .append("// Codename One: kotlin-stdlib ").append(MERGED_STDLIB_FLOOR) + .append(" absorbed the kotlin-stdlib-jdk7 and kotlin-stdlib-jdk8\n") + .append("// classes and the 1.8.x line ships no Gradle module metadata saying so, so\n") + .append("// a graph holding stdlib at or above that and an older shim carries the same\n") + .append("// classes twice and fails checkDuplicateClasses. This states the overlap as a\n") + .append("// capability and lets Gradle drop the superseded shim. It raises no version,\n") + .append("// so it cannot conflict with a pin, a force, a BOM or the Kotlin in use.\n") + .append("// Turn it off with the build hint android.kotlinStdlibAlignment=false.\n") + .append("dependencies {\n") + .append(" components.withModule('org.jetbrains.kotlin:kotlin-stdlib') { details ->\n") + .append(versionGuard(" ", atOrAbove)) + .append(" allVariants {\n") + .append(" withCapabilities {\n"); + for (int i = 0; i < ALIGNED_ARTIFACTS.length; i++) { + out.append(" addCapability('").append(CAPABILITY_GROUP) + .append("', '").append(ALIGNED_ARTIFACTS[i]).append(CAPABILITY_SUFFIX) + .append("', details.id.version)\n"); + } + out.append(" }\n") + .append(" }\n") + .append(versionGuardEnd(" ")) + .append(" }\n"); + for (int i = 0; i < ALIGNED_ARTIFACTS.length; i++) { + out.append(" components.withModule('org.jetbrains.kotlin:") + .append(ALIGNED_ARTIFACTS[i]).append("') { details ->\n") + .append(versionGuard(" ", below)) + .append(" allVariants {\n") + .append(" withCapabilities {\n") + .append(" addCapability('").append(CAPABILITY_GROUP) + .append("', '").append(ALIGNED_ARTIFACTS[i]).append(CAPABILITY_SUFFIX) + .append("', details.id.version)\n") + .append(" }\n") + .append(" }\n") + .append(versionGuardEnd(" ")) + .append(" }\n"); + } + out.append("}\n") + .append("configurations.all {\n") + // Review asked whether selecting a stdlib that is reachable only + // THROUGH the shims makes resolution expand until the daemon runs out + // of heap. It does not: kotlin-stdlib-jdk8:1.7.0 as the only route to + // the stdlib, plus a force to 1.8.0, resolves in seconds under a + // 512MB heap on both Gradle 8.5 and 8.14.2 -- and it is a graph this + // FIXES, since the baseline there carries the duplicate. + .append(" resolutionStrategy.capabilitiesResolution {\n"); + for (int i = 0; i < ALIGNED_ARTIFACTS.length; i++) { + out.append(" withCapability('").append(CAPABILITY_GROUP).append(':') + .append(ALIGNED_ARTIFACTS[i]).append(CAPABILITY_SUFFIX).append("') {\n") + .append(" def stdlib = candidates.find {\n") + .append(" it.id instanceof org.gradle.api.artifacts.component" + + ".ModuleComponentIdentifier &&\n") + .append(" it.id.module == 'kotlin-stdlib'\n") + .append(" }\n") + .append(" if (stdlib != null) {\n") + .append(" select(stdlib)\n") + .append(" }\n") + .append(" }\n"); + } + out.append(" }\n") + .append("}\n"); + return out.toString(); + } + + /** Opens a try block that reads the module version and tests {@code test}. */ + private static String versionGuard(String indent, String test) { + return indent + "try {\n" + + indent + " def parts = details.id.version.split('[.-]')\n" + + indent + " def major = parts[0].toInteger()\n" + + indent + " def minor = parts[1].toInteger()\n" + + indent + " if (" + test + ") {\n"; + } + + /** + * Closes it. A version this cannot read is left alone -- doing nothing + * leaves the duplicate the app already had, and guessing could drop a shim + * whose classes are still the only copy. + */ + private static String versionGuardEnd(String indent) { + return indent + " }\n" + + indent + "} catch (Exception ignored) {\n" + + indent + "}\n"; + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/ExecutorProcessTimeoutTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/ExecutorProcessTimeoutTest.java index e3f9fd0a12e..46ceb5ee683 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/ExecutorProcessTimeoutTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/ExecutorProcessTimeoutTest.java @@ -121,10 +121,31 @@ void aProcessThatSucceedsIsNotReportedAsTimedOutWhileOutputIsStillDraining() thr // on the iOS and native steps, which are the ones that run with a timeout. // // Reproducing it needs the join to actually block, which means the pipe - // must outlive the process. Without the fix the watcher fires at 1000ms - // during that wait and exec returns 1. + // must outlive the process. Without the fix the watcher fires during that + // wait and exec returns 1. + // + // The numbers are sized off a measurement, not chosen. At the 1000ms this + // started with, the test was racing its own JVM: a BARE java launch that + // runs an empty main and exits takes 570-993ms on the machine this was + // written on, and this helper also spawns a child before exiting -- so the + // process legitimately outlived the deadline, the watcher legitimately + // fired, and the test failed 2 runs in 5 with nothing else running. That is + // an assertion about the speed of a JVM launch wearing the name of one + // about timeout accounting. + // + // 2500ms was tried and still failed 1 run in 8 while other work shared the + // machine, which is the condition every CI runner is in. 5000ms is ~5x the + // worst launch observed, and the pipe is held 8000ms so the deadline still + // falls comfortably INSIDE the join -- which is what the regression needs: + // with the bug the watcher fires at 5000ms while the join is waiting on a + // process that exited long before, and rc is 1 again. Verified by putting + // the bug back. + // + // The cost is that a passing run takes about as long as the pipe is held, + // since exec returns when the reader drains. Eight seconds of wall clock + // buys a test that measures what it says it measures. TestExecutor e = new TestExecutor(); - int rc = e.executeProcess(javaProcess(ExitsLeavingChildHoldingOutput.class, "4000"), 1000); + int rc = e.executeProcess(javaProcess(ExitsLeavingChildHoldingOutput.class, "8000"), 5000); assertEquals(0, rc, "a command that exited 0 must not be reported as timed out"); } diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java new file mode 100644 index 00000000000..03fca49528d --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/KotlinStdlibAlignmentTest.java @@ -0,0 +1,218 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.builders; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The alignment emits one script and takes no input, so there is little here. + * + *

What the script MEANS was measured against a real Gradle 6.5 and 8.5 + * resolving from Maven Central -- the duplicate graph, a strict pin, a reject, a + * force, an enforced BOM, a range, an all-1.7 project and a Kotlin-free one. No + * unit test can see resolution, so these pin the properties that make those + * outcomes hold, and the class javadoc records the runs.

+ */ +class KotlinStdlibAlignmentTest { + + /** The overlap is stated as a capability, for both shims. */ + @Test + void theScriptStatesTheOverlapAsACapability() { + String s = KotlinStdlibAlignment.alignmentScript(); + assertTrue(s.contains("components.withModule('org.jetbrains.kotlin:kotlin-stdlib')"), + "the rule is on kotlin-stdlib, which is what gained the classes"); + assertTrue(s.contains("addCapability('com.codenameone', 'kotlin-stdlib-jdk7-superseded'"), + "jdk7"); + assertTrue(s.contains("addCapability('com.codenameone', 'kotlin-stdlib-jdk8-superseded'"), + "jdk8"); + } + + /** + * NO VERSION IS EVER RAISED. This is the whole reason the class has no + * inputs: a constraint raises a version, which an app can be holding down, + * and detecting that from Gradle text is unbounded. A capability moves + * nothing, so a shim version must appear nowhere as a requested version. + */ + @Test + void theScriptRequiresNoVersionOfAnything() { + // The DIRECTIVES, not the prose: the script's own comment explains that + // it cannot conflict with a force, and matching that read as the script + // issuing one. + StringBuilder code = new StringBuilder(); + String[] lines = KotlinStdlibAlignment.alignmentScript().split("\n"); + for (int i = 0; i < lines.length; i++) { + if (!lines[i].trim().startsWith("//")) { + code.append(lines[i]).append('\n'); + } + } + String s = code.toString(); + assertTrue(!s.contains("constraints {"), + "a constraints block would raise a version: " + s); + assertTrue(!s.contains("kotlin-stdlib-jdk7:" + KotlinStdlibAlignment.MERGED_STDLIB_FLOOR) + && !s.contains("kotlin-stdlib-jdk8:" + + KotlinStdlibAlignment.MERGED_STDLIB_FLOOR), + "no shim is asked for at a version"); + assertTrue(!s.contains("strictly") && !s.contains("force") + && !s.contains("substitute"), + "and nothing else moves a version either"); + } + + /** + * The capability is declared only from the floor up. Below it the shims + * still carry the only copy of their classes, so dropping one would remove + * them -- and a project compiling against an older Kotlin keeps its own + * stdlib untouched, which is why the compiler version cannot be a problem. + */ + @Test + void theCapabilityStartsWhereTheClassesMoved() { + String s = KotlinStdlibAlignment.alignmentScript(); + assertTrue("1.8.0".equals(KotlinStdlibAlignment.MERGED_STDLIB_FLOOR), + "1.8.0 is where kotlin-stdlib absorbed the jdk7/jdk8 classes"); + assertTrue(s.contains("major > 1 || (major == 1 && minor >= 8)"), + "the guard is derived from that floor: " + s); + } + + /** The conflict resolves to the stdlib, never to whichever version is higher. */ + @Test + void theConflictResolvesToTheStdlib() { + String s = KotlinStdlibAlignment.alignmentScript(); + assertTrue(s.contains("withCapability('com.codenameone:kotlin-stdlib-jdk7-superseded')") + && s.contains("withCapability('com.codenameone:kotlin-stdlib-jdk8-superseded')"), + "both capabilities are resolved"); + assertTrue(s.contains("def stdlib = candidates.find {") + && s.contains("it.id.module == 'kotlin-stdlib'"), + "the stdlib is the candidate selected"); + assertTrue(s.contains("if (stdlib != null)"), + "and it is not selected when it is absent"); + assertTrue(s.contains("it.id instanceof org.gradle.api.artifacts.component" + + ".ModuleComponentIdentifier"), + "a project candidate has no module property; reading one throws " + + "MissingPropertyException and Gradle reports " + + "'Capability resolution rule failed'"); + } + + @Test + void theShimOnlyClaimsToBeSupersededBelowTheFloor() { + // The half that keeps a NEWER shim alive. A shim at or above the floor + // is empty and duplicates nothing, and its only contribution is a + // requirement on a stdlib at its own version. Making it conflict evicts + // it and that requirement with it, silently downgrading the base module + // -- measured: stdlib 1.8.0 with kotlin-stdlib-jdk8 1.9.0 resolves to + // 1.9.0 untouched, and to 1.8.0 when the shim is made to conflict. + String s = KotlinStdlibAlignment.alignmentScript(); + int shimRule = s.indexOf( + "components.withModule('org.jetbrains.kotlin:kotlin-stdlib-jdk8')"); + assertTrue(shimRule >= 0, "the shim carries a rule of its own: " + s); + String rule = s.substring(shimRule); + rule = rule.substring(0, rule.indexOf("\n }")); + assertTrue(rule.contains("minor < 8"), + "and it claims to be superseded only BELOW the floor: " + rule); + assertTrue(!rule.contains("minor >= 8"), + "never at or above it: " + rule); + } + + @Test + void theCapabilityIsOursAndNotTheShimsOwn() { + // Reusing the shims' implicit capability makes every version of a shim + // conflict with the stdlib, including the empty ones, and that conflict + // has no right answer: dropping the shim downgrades the base module, + // dropping the stdlib leaves empty shims and no stdlib at all. Both were + // measured. A capability only this declares is held by exactly the two + // modules that actually overlap. + String s = KotlinStdlibAlignment.alignmentScript(); + assertTrue(!s.contains("addCapability('org.jetbrains.kotlin'"), + "the shims' own capability is never reused: " + s); + assertTrue(s.contains("'com.codenameone'"), + "the capability is ours"); + } + + /** + * The rule runs on every AndroidX build, so its worst case has to be + * "do nothing". A version it cannot parse leaves the graph exactly as it + * found it, which is the duplicate the app already had. + */ + @Test + void theRuleCannotFailTheBuild() { + String s = KotlinStdlibAlignment.alignmentScript(); + int rule = s.indexOf("components.withModule"); + int guard = s.indexOf("try {"); + int caught = s.indexOf("catch (Exception ignored)"); + assertTrue(rule >= 0 && guard > rule && caught > guard, + "the version read is inside a try/catch: " + s); + } + + /** + * It carries its own scopes. The metadata rule has to be inside + * {@code dependencies} and the resolution strategy outside it, so the script + * opens both rather than being spliced into two places by the caller. + */ + @Test + void theScriptBringsItsOwnScopes() { + String s = KotlinStdlibAlignment.alignmentScript(); + assertTrue(s.contains("dependencies {") && s.contains("configurations.all {"), + "both scopes: " + s); + assertTrue(s.contains("android.kotlinStdlibAlignment=false"), + "and it names the hint that switches it off, in the generated file " + + "where somebody debugging a build will actually see it"); + } + + @Test + void theGateIsNotAboutAndroidX() throws Exception { + // The duplicate has nothing to do with AndroidX. Reproduced with + // android.useAndroidX=false explicitly set on AGP 8.1.4: + // checkDebugDuplicateClasses fails there exactly as it does with + // AndroidX on, so gating on it left those builds broken. + String src = new String(java.nio.file.Files.readAllBytes(new java.io.File( + "src/main/java/com/codename1/builders/AndroidGradleBuilder.java").toPath()), "UTF-8"); + int at = src.indexOf("String kotlinStdlibAlignment = \"\";"); + assertTrue(at >= 0, "the builder has the alignment gate"); + String gate = src.substring(at, src.indexOf("}", src.indexOf("if (", at))); + assertTrue(!gate.contains("useAndroidX"), + "the gate does not turn on AndroidX: " + gate); + assertTrue(gate.contains("gradleVersionInt >= 6"), + "it does keep the Gradle floor, which capabilitiesResolution needs"); + assertTrue(gate.contains("android.kotlinStdlibAlignment"), + "and the opt-out hint"); + } + + /** + * Appended AFTER the dependencies block. Inside it, the + * {@code configurations.all} half would be a syntax error in the generated + * script -- which no unit test on the emitted string alone would catch. + */ + @Test + void theBuilderAppendsItAfterTheDependenciesBlock() throws Exception { + String src = new String(java.nio.file.Files.readAllBytes(new java.io.File( + "src/main/java/com/codename1/builders/AndroidGradleBuilder.java").toPath()), "UTF-8"); + int at = src.indexOf("+ kotlinStdlibAlignment"); + assertTrue(at >= 0, "the builder appends the alignment"); + String before = src.substring(0, at); + assertTrue(before.lastIndexOf("+ \"}\\n\"") > before.lastIndexOf("\"dependencies {"), + "the dependencies block is closed before the alignment is appended"); + assertTrue(src.contains("KotlinStdlibAlignment.alignmentScript()"), + "and it is the whole script, with no arguments to get wrong"); + } +} +